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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
// The id marker: the slug, kept tracker-side.
|
||||
//
|
||||
// Push deletes the local file once the tracker has confirmed the write, so the
|
||||
// slug — the issue's ONLY identity in the domain — cannot live only on this
|
||||
// machine any more. It rides up in the body as an HTML comment:
|
||||
//
|
||||
// <!-- kettle:id wire-sqlc-appclick -->
|
||||
//
|
||||
// Why the body and not a local number -> slug ledger: the ledger is a local
|
||||
// file, and "the local copy is not the record" is the whole point of deleting
|
||||
// one. A marker in the body survives a rename in the web UI, a lost ledger, a
|
||||
// fresh clone, and a second machine — none of which the ledger does. Why an
|
||||
// HTML comment: Gitea renders markdown, so it is invisible to a human reader,
|
||||
// and it comes back verbatim on every API read.
|
||||
//
|
||||
// WHERE: the first line of the tracker-side body, followed by one blank line.
|
||||
// First because it is the one position that does not depend on what sections
|
||||
// the issue happens to have, and because a human who does look at the raw
|
||||
// markdown finds it before the prose rather than buried in it.
|
||||
//
|
||||
// WHAT THE LOCAL FILE SEES: nothing. FromPayload strips every marker before the
|
||||
// body reaches the store, so `.kettle/issues/<id>.md` holds exactly what the
|
||||
// author wrote — checkbox line numbers, `kettle check`, and diffs are all
|
||||
// unaffected, and the slug is already the file's name, so a copy of it in the
|
||||
// body would be duplicated state.
|
||||
//
|
||||
// WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
|
||||
// strip-all-then-prepend-one. WithIDMarker never appends to what is there, and
|
||||
// StripIDMarker removes EVERY marker line, not the first. So a body that
|
||||
// somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on
|
||||
// the next pull and goes back up with exactly one. There is no code path that
|
||||
// adds a marker to a body that has not just been stripped.
|
||||
//
|
||||
// WHY TWO SPELLINGS ARE READ AND ONE IS WRITTEN: this tool was called `tea`
|
||||
// and wrote `<!-- tea:id … -->`. Issues pushed under that name are sitting in
|
||||
// the tracker right now, and their local files are gone — the marker is the
|
||||
// only copy of their slug there is. A rename that stopped reading the old
|
||||
// spelling would orphan every one of them: the pull would fall back to the
|
||||
// title, allocate a fresh slug, and every `depends:` pointing at the old one
|
||||
// would dangle. So the writer moved and the reader did not.
|
||||
var markerRe = regexp.MustCompile(
|
||||
`^[ \t]*<!--[ \t]*(?:kettle|tea):id[ \t]+(\S+)[ \t]*-->[ \t]*$`)
|
||||
|
||||
// IDMarker is the marker line for a slug. One place formats it, one regex
|
||||
// reads it — and what that regex accepts is deliberately wider than this.
|
||||
func IDMarker(id string) string { return "<!-- kettle:id " + id + " -->" }
|
||||
|
||||
// IDInBody is the slug a tracker-side body claims, or "" when it claims none.
|
||||
//
|
||||
// The FIRST valid marker wins; a second one is ignored here and removed by
|
||||
// StripIDMarker on the way in. The captured text must be a slug by the domain's
|
||||
// own rule — a marker holding anything else is not a slug and is treated as if
|
||||
// it were not there, so a mangled comment falls back to the title instead of
|
||||
// naming a file after garbage.
|
||||
func IDInBody(body string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if m := markerRe.FindStringSubmatch(strings.TrimSuffix(line, "\r")); m != nil {
|
||||
if issue.IsSlug(m[1]) {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StripIDMarker is body with every marker line removed, in either spelling.
|
||||
// Idempotent.
|
||||
//
|
||||
// A body that carries no marker is returned byte for byte — the common case (an
|
||||
// issue filed in the web UI) costs nothing and is not reformatted. When a marker
|
||||
// is removed from the top, the blank line it was written with goes with it, so
|
||||
// the round trip is exact: StripIDMarker(WithIDMarker(b, id)) == b.
|
||||
func StripIDMarker(body string) string {
|
||||
lines := strings.Split(body, "\n")
|
||||
found := false
|
||||
for _, line := range lines {
|
||||
if markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return body
|
||||
}
|
||||
kept := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if !markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(strings.Join(kept, "\n"), "\n")
|
||||
}
|
||||
|
||||
// WithIDMarker is body with exactly one marker, as its first line.
|
||||
//
|
||||
// Strip-then-prepend, always — that is the guarantee that a body can never end
|
||||
// up with two, however many it arrived with, and it is what quietly rewrites a
|
||||
// `tea:id` marker into the current spelling the next time the issue is pushed.
|
||||
func WithIDMarker(body, id string) string {
|
||||
return IDMarker(id) + "\n\n" + StripIDMarker(body)
|
||||
}
|
||||
Reference in New Issue
Block a user