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>
105 lines
3.5 KiB
Go
105 lines
3.5 KiB
Go
package mapping
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
|
)
|
|
|
|
func TestIDInBodyReadsBothSpellings(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want string
|
|
}{
|
|
{"the spelling this tool writes", "<!-- kettle:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
|
|
// The whole reason the reader is wider than the writer.
|
|
{"the spelling already in the tracker", "<!-- tea:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
|
|
{"indented and loosely spaced", " <!-- tea:id wire-sqlc --> \n", "wire-sqlc"},
|
|
{"no marker at all", "## Summary\nx", ""},
|
|
// A mangled comment falls back to the title rather than naming a file
|
|
// after garbage.
|
|
{"not a slug", "<!-- kettle:id Wire_SQLC -->\n", ""},
|
|
{"not on a line of its own", "text <!-- kettle:id wire-sqlc -->\n", ""},
|
|
{"the first valid marker wins", "<!-- tea:id first-one -->\n<!-- kettle:id second-one -->\n", "first-one"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := IDInBody(c.body); got != c.want {
|
|
t.Errorf("IDInBody = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An issue pushed under the old name is sitting in the tracker with its local
|
|
// file long since deleted — the marker is the only copy of its slug there is.
|
|
// It has to keep resolving, and it has to come back up in the new spelling.
|
|
func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
|
|
const inTracker = "<!-- tea:id wire-sqlc-appclick -->\n\n## Summary\nПроводка sqlc.\n"
|
|
|
|
id := IDInBody(inTracker)
|
|
if id != "wire-sqlc-appclick" {
|
|
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
|
|
}
|
|
|
|
iss, _ := FromPayload(&wire.Issue{Number: 42, Title: "Wire sqlc", Body: inTracker},
|
|
id, tea, PayloadOptions{})
|
|
if strings.Contains(iss.Body, "tea:id") {
|
|
t.Errorf("the old marker reached the local copy: %q", iss.Body)
|
|
}
|
|
if iss.Body != "## Summary\nПроводка sqlc." {
|
|
t.Errorf("body = %q", iss.Body)
|
|
}
|
|
|
|
// And the next push rewrites it into the current spelling, without ever
|
|
// having two.
|
|
up := *ToRequest(iss, RequestOptions{}).Body
|
|
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
|
|
t.Errorf("the marker was not rewritten: %q", up)
|
|
}
|
|
if strings.Contains(up, "tea:id") {
|
|
t.Errorf("both spellings went up: %q", up)
|
|
}
|
|
if n := strings.Count(up, ":id "); n != 1 {
|
|
t.Errorf("%d markers in the body, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestMarkersCannotAccumulate(t *testing.T) {
|
|
body := "## Summary\nx"
|
|
// Whatever it arrived with — one, the other, several — it goes up with one.
|
|
messy := "<!-- tea:id old-one -->\n\n<!-- kettle:id other-one -->\n\n" + body
|
|
got := WithIDMarker(messy, "real-one")
|
|
|
|
if want := IDMarker("real-one") + "\n\n" + body; got != want {
|
|
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
|
}
|
|
if got := WithIDMarker(got, "real-one"); strings.Count(got, "<!--") != 1 {
|
|
t.Errorf("a second pass added one: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStripIsTheExactInverseOfWith(t *testing.T) {
|
|
bodies := []string{
|
|
"## Summary\nx",
|
|
"## Summary\nx\n\n## Acceptance criteria\n- [ ] один\n",
|
|
"",
|
|
}
|
|
for _, b := range bodies {
|
|
if got := StripIDMarker(WithIDMarker(b, "an-id")); got != b {
|
|
t.Errorf("StripIDMarker(WithIDMarker(%q)) = %q", b, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The common case — an issue filed in the web UI — costs nothing and is not
|
|
// reformatted.
|
|
func TestStripLeavesAnUnmarkedBodyByteForByte(t *testing.T) {
|
|
body := "\n\n## Summary\nx\n\n\n"
|
|
if got := StripIDMarker(body); got != body {
|
|
t.Errorf("StripIDMarker rewrote a body with no marker in it: %q", got)
|
|
}
|
|
}
|