Files
marketplace/cli/internal/gitea/remotemap.go
T
naudachu 9480e48312 feat: add the kettle CLI, replacing the plugin's Python scripts
The plugin resolved its issue store from `__file__`, which put it inside a
versioned plugin cache: issues written from one project were invisible from the
next, and `origin: local` files — the only copy of that work by definition —
were stranded a version bump at a time. The walk that answers "which directory
is the project" was written three times over, and in a linked worktree the three
disagreed. Both are runtime failures rather than logic ones, so the fix is a
compiled binary: one walk, imported rather than re-derived, and a layering rule
the build graph enforces instead of a grep.

Seven packages, knowledge flowing one way. `project` answers which directory is
the project and depends on nothing. `issue` is the domain — format, taxonomy,
validation, checkboxes, dependency graph, the store, eviction — offline, with no
tracker in it. `wire` holds the protocol shapes. `gitea` is the transport,
`mapping` the bridge, `config` the credentials, `cmd` the command tree. Four
tests hold the boundaries, each failing on a real mistake rather than a naming
convention.

The marker moves to `.kettle/` and the login pin moves out of the harness's
settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens
live in one file per machine, mode 0600, outside every working tree. That
retires the PreToolUse guard hook entirely — the binary holds its own
credentials, so a command running under a login nobody chose is not expressible
rather than caught.

`kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a
move: a store left behind at an old path is one somebody edits by accident
months later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:05:39 +05:00

89 lines
3.5 KiB
Go

package gitea
import (
"encoding/json"
"os"
"path/filepath"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// RemoteMapName is the ledger's file name, beside the issues it indexes.
const RemoteMapName = ".remote.json"
// RemoteMap is the number -> slug ledger: {"owner/repo#42": "wire-sqlc-appclick"}.
//
// ITS ENTRIES OUTLIVE THE FILES THEY NAME, and that is deliberate rather than a
// leak. A push deletes an issue's file the moment the tracker confirms the
// write, and the entry left behind is what makes the next pull of that number
// land on the same slug — so every `depends:` that pointed at it still
// resolves. Nothing prunes them, because "no file" no longer means "no such
// issue"; eviction does not prune it either, for the same reason a push does
// not. A stale entry costs one line of JSON and is corrected the next time that
// number is pulled.
//
// It is a cache, not a record. The slug also travels tracker-side, in the issue
// body, so losing this file costs a re-pull and not information — which is why
// Load never fails and why a rebuild is a MERGE and never a replacement. The
// order of authority:
//
// the tracker the issue, and the marker naming its slug
// .remote.json a local number -> slug ledger, a cache of that marker
// the store whatever happens to be checked out right now
//
// The store is a subset of what the ledger knows, so a rebuild that started
// from the files alone would throw away every entry it cannot see. Start from
// Load, add what the files say, Save.
type RemoteMap map[string]string
// RemoteMapPath is where the ledger lives: inside the issue store, beside the
// issues. root is the STORE, not the payload scratchpad — this file is
// bookkeeping about issues and belongs where they are.
func RemoteMapPath(root string) string { return filepath.Join(root, RemoteMapName) }
// LoadRemoteMap reads the ledger.
//
// A missing, unreadable or malformed file is an empty ledger and never an
// error. The ledger is a cache of markers the tracker holds, so refusing to run
// because it cannot be parsed would block the very pull that would rebuild it —
// and the cost of starting empty is one re-pull, never a lost issue.
func LoadRemoteMap(root string) RemoteMap {
raw, err := os.ReadFile(RemoteMapPath(root))
if err != nil {
return RemoteMap{}
}
var got RemoteMap
if err := json.Unmarshal(raw, &got); err != nil || got == nil {
return RemoteMap{}
}
return got
}
// Save writes the ledger, creating the directory if it is not there.
//
// The one write in this package allowed to create the store, and only because
// of when it happens: the ledger is written the instant the tracker confirms a
// push and BEFORE the local file is deleted, so failing it over a missing
// directory would lose the slug at exactly the moment the local copy stops
// being the record.
//
// Indented and key-sorted — encoding/json sorts map keys for us — because this
// file is read by people and diffed by git as often as it is read by the
// binary.
func (m RemoteMap) Save(root string) error {
if err := os.MkdirAll(root, 0o755); err != nil {
return err
}
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(RemoteMapPath(root), append(raw, '\n'), 0o644)
}
// Slug is the local name recorded for a key, or "".
func (m RemoteMap) Slug(k wire.Key) string { return m[k.String()] }
// Set records that a key is known locally under this slug.
func (m RemoteMap) Set(k wire.Key, slug string) { m[k.String()] = slug }