9480e48312
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>
89 lines
3.5 KiB
Go
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 }
|