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>
382 lines
13 KiB
Go
382 lines
13 KiB
Go
package mapping
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
|
)
|
|
|
|
// The repository the fixtures are pushed to. A wire.Repo and not a string: the
|
|
// handle in `gitea:` is a key, and a key is a repository and a number.
|
|
var tea = wire.Repo{Owner: "claude-skills", Name: "tea"}
|
|
|
|
// A file exactly as the store holds it: domain fields, then the sync fields the
|
|
// domain carries and never reads.
|
|
const stored = `---
|
|
id: wire-sqlc-appclick
|
|
state: open
|
|
labels: [type/task, tech/sql]
|
|
assignees: [naudachu]
|
|
milestone: v0.2
|
|
depends: [migrate-schema]
|
|
origin: gitea
|
|
branch: feat/wire-sqlc
|
|
gitea: claude-skills/tea#42
|
|
synced: 2026-08-09T18:40:00Z
|
|
---
|
|
# Wire sqlc into the appclick repo layer
|
|
|
|
## Summary
|
|
Проводка sqlc в слой репозиториев.
|
|
|
|
## Spec
|
|
none
|
|
|
|
## Depends on
|
|
- #7 — нужна схема БД из этого issue
|
|
|
|
## Acceptance criteria
|
|
- [x] сгенерирован код
|
|
- [ ] тесты зелёные
|
|
`
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func roundTripOptions() RequestOptions {
|
|
return RequestOptions{
|
|
LabelIDs: map[string]int64{"type/task": 11, "tech/sql": 12},
|
|
MilestoneID: ptr(int64(5)),
|
|
IncludeState: true,
|
|
}
|
|
}
|
|
|
|
// The whole point of the package in one test: everything the format says is
|
|
// preserved comes back, and the body comes back byte for byte.
|
|
func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
|
|
local := issue.FromText(stored, "wire-sqlc-appclick")
|
|
req := ToRequest(local, roundTripOptions())
|
|
|
|
if req.Title == nil || *req.Title != local.Title {
|
|
t.Errorf("title = %v, want %q", req.Title, local.Title)
|
|
}
|
|
if req.Body == nil {
|
|
t.Fatal("the request carries no body — a create would file an empty issue")
|
|
}
|
|
if got := IDInBody(*req.Body); got != local.ID {
|
|
t.Errorf("the request body does not claim the slug: %q", got)
|
|
}
|
|
if got := StripIDMarker(*req.Body); got != strings.TrimSpace(local.Body) {
|
|
t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s",
|
|
got, strings.TrimSpace(local.Body))
|
|
}
|
|
if want := []int64{11, 12}; req.Labels == nil || !reflect.DeepEqual(*req.Labels, want) {
|
|
t.Errorf("labels = %v, want %v", req.Labels, want)
|
|
}
|
|
if want := []string{"naudachu"}; req.Assignees == nil || !reflect.DeepEqual(*req.Assignees, want) {
|
|
t.Errorf("assignees = %v, want %v", req.Assignees, want)
|
|
}
|
|
if req.Milestone == nil || *req.Milestone != 5 {
|
|
t.Errorf("milestone = %v, want 5", req.Milestone)
|
|
}
|
|
if req.State == nil || *req.State != "open" {
|
|
t.Errorf("state = %v", req.State)
|
|
}
|
|
if req.Ref == nil || *req.Ref != "feat/wire-sqlc" {
|
|
t.Errorf("ref = %v — branch: is a sync field and must ride along", req.Ref)
|
|
}
|
|
|
|
// What the tracker hands back is the body it was given, plus its own
|
|
// bookkeeping.
|
|
echo := &wire.Issue{
|
|
Number: 42,
|
|
Title: *req.Title,
|
|
Body: *req.Body,
|
|
State: "open",
|
|
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42",
|
|
UpdatedAt: "2026-08-09T18:24:01Z",
|
|
Ref: *req.Ref,
|
|
Comments: 3,
|
|
Labels: []wire.Label{{Name: "type/task"}, {Name: "tech/sql"}},
|
|
Assignees: []wire.User{{Login: "naudachu"}},
|
|
Milestone: &wire.Milestone{ID: 5, Title: "v0.2"},
|
|
}
|
|
back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{
|
|
IDForNumber: map[int]string{7: "migrate-schema"},
|
|
Synced: "2026-08-09T18:40:00Z",
|
|
})
|
|
if len(unresolved) != 0 {
|
|
t.Errorf("unresolved = %v, want none", unresolved)
|
|
}
|
|
|
|
if back.Body != strings.TrimSpace(local.Body) {
|
|
t.Errorf("the body did not survive the trip:\n--- got ---\n%s\n--- want ---\n%s",
|
|
back.Body, strings.TrimSpace(local.Body))
|
|
}
|
|
if strings.Contains(back.Body, "kettle:id") || strings.Contains(back.Body, "tea:id") {
|
|
t.Error("the marker reached the local copy — it is transport bookkeeping and belongs nowhere near disk")
|
|
}
|
|
for _, c := range []struct{ name, got, want string }{
|
|
{"id", back.ID, local.ID},
|
|
{"title", back.Title, local.Title},
|
|
{"state", back.State, local.State},
|
|
{"milestone", back.Milestone, local.Milestone},
|
|
{"origin", back.Origin, local.Origin},
|
|
{"gitea", back.Extra[GiteaKey], "claude-skills/tea#42"},
|
|
{"branch", back.Extra[BranchKey], "feat/wire-sqlc"},
|
|
{"synced", back.Extra[SyncedKey], "2026-08-09T18:40:00Z"},
|
|
{"url", back.Extra[URLKey], "https://git.noodles.cam/claude-skills/tea/issues/42"},
|
|
{"remote-updated", back.Extra[RemoteUpdatedKey], "2026-08-09T18:24:01Z"},
|
|
{"comments", back.Extra[CommentsKey], "3"},
|
|
} {
|
|
if c.got != c.want {
|
|
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(back.Labels, local.Labels) {
|
|
t.Errorf("labels = %v, want %v", back.Labels, local.Labels)
|
|
}
|
|
if !reflect.DeepEqual(back.Assignees, local.Assignees) {
|
|
t.Errorf("assignees = %v, want %v", back.Assignees, local.Assignees)
|
|
}
|
|
// `depends:` is slugs; the `#7` the prose names is translated at this edge
|
|
// and the prose itself is left alone.
|
|
if !reflect.DeepEqual(back.Depends, local.Depends) {
|
|
t.Errorf("depends = %v, want %v", back.Depends, local.Depends)
|
|
}
|
|
if !strings.Contains(back.Body, "- #7 — нужна схема БД из этого issue") {
|
|
t.Error("the ## Depends on prose was rewritten; it is the author's text and passes through unchanged")
|
|
}
|
|
|
|
// And the strongest form of "no churn": pushing what came back sends
|
|
// exactly what was sent the first time.
|
|
if again := ToRequest(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
|
|
t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req)
|
|
}
|
|
}
|
|
|
|
// The other shape an issue comes in: nothing scheduled, nobody assigned.
|
|
func TestNoMilestoneAndNoAssignees(t *testing.T) {
|
|
local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+
|
|
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+
|
|
"# A lone issue\n\n## Summary\nОдин.\n", "lone")
|
|
|
|
req := ToRequest(local, RequestOptions{LabelIDs: map[string]int64{"type/task": 11}})
|
|
if req.Assignees != nil {
|
|
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", req.Assignees)
|
|
}
|
|
if req.Milestone != nil {
|
|
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", req.Milestone)
|
|
}
|
|
|
|
raw, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
body := string(raw)
|
|
for _, key := range []string{`"assignees"`, `"milestone"`, `"state"`, `"ref"`} {
|
|
if strings.Contains(body, key) {
|
|
t.Errorf("%s is in the request body; on a PATCH that overwrites what the tracker holds: %s", key, body)
|
|
}
|
|
}
|
|
// A resolved-but-empty label set is the opposite statement and must be sent.
|
|
if !strings.Contains(body, `"labels":[11]`) {
|
|
t.Errorf("labels missing from %s", body)
|
|
}
|
|
empty, err := json.Marshal(ToRequest(local, RequestOptions{LabelIDs: map[string]int64{}}))
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if !strings.Contains(string(empty), `"labels":[]`) {
|
|
t.Errorf("a resolved label set that matched nothing must still be sent as []: %s", empty)
|
|
}
|
|
silent, err := json.Marshal(ToRequest(local, RequestOptions{}))
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if strings.Contains(string(silent), `"labels"`) {
|
|
t.Errorf("a caller that resolved no ids must not clear the tracker's labels: %s", silent)
|
|
}
|
|
|
|
back, unresolved := FromPayload(&wire.Issue{
|
|
Number: 9,
|
|
Title: "A lone issue",
|
|
Body: WithIDMarker("## Summary\nОдин.", "lone"),
|
|
State: "open",
|
|
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9",
|
|
}, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"})
|
|
|
|
if len(unresolved) != 0 {
|
|
t.Errorf("unresolved = %v", unresolved)
|
|
}
|
|
if back.Milestone != "" || back.Assignees != nil || back.Labels != nil {
|
|
t.Errorf("empty came back as something: milestone=%q assignees=%v labels=%v",
|
|
back.Milestone, back.Assignees, back.Labels)
|
|
}
|
|
if _, ok := back.Extra[BranchKey]; ok {
|
|
t.Error("an absent ref must not write an empty branch: field")
|
|
}
|
|
if _, ok := back.Extra[CommentsKey]; ok {
|
|
t.Error("zero comments is not a fact worth a line in the file")
|
|
}
|
|
if !strings.Contains(back.Text(), "milestone: none") {
|
|
t.Error("an empty milestone must render back as none")
|
|
}
|
|
}
|
|
|
|
// A dependency whose target is not in the store yet is reported, never invented:
|
|
// a made-up slug is an edge to a file that does not exist.
|
|
func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
|
|
body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n"
|
|
back, unresolved := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body},
|
|
"here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}})
|
|
|
|
if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) {
|
|
t.Errorf("depends = %v, want %v", back.Depends, want)
|
|
}
|
|
if want := []int{8}; !reflect.DeepEqual(unresolved, want) {
|
|
t.Errorf("unresolved = %v, want %v", unresolved, want)
|
|
}
|
|
if !strings.Contains(back.Body, "- #8") {
|
|
t.Error("the body still names it, which is why dropping it from depends: loses nothing")
|
|
}
|
|
}
|
|
|
|
func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) {
|
|
body := "## Summary\nx\n\n## Depends on\n- #7\n"
|
|
back, _ := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
|
|
PayloadOptions{
|
|
IDForNumber: map[int]string{7: "seven", 9: "nine"},
|
|
ExtraNumbers: []int{7, 9},
|
|
})
|
|
if want := []string{"seven", "nine"}; !reflect.DeepEqual(back.Depends, want) {
|
|
t.Errorf("depends = %v, want %v", back.Depends, want)
|
|
}
|
|
}
|
|
|
|
// The one exception to "a pull overwrites the body", and the narrowest one that
|
|
// works: a tick only ever travels one way, so the two sides are a set union.
|
|
func TestMergeCheckboxState(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
remote, local string
|
|
want string
|
|
wantUnchangedRef bool
|
|
}{
|
|
{
|
|
name: "a local tick survives the overwrite",
|
|
remote: "- [ ] один\n- [ ] два\n",
|
|
local: "- [x] два\n",
|
|
want: "- [ ] один\n- [x] два\n",
|
|
},
|
|
{
|
|
name: "rewrapping an item does not cost it its tick",
|
|
remote: "- [ ] очень длинный\n пункт\n",
|
|
local: "- [x] очень длинный пункт\n",
|
|
want: "- [x] очень длинный\n пункт\n",
|
|
},
|
|
{
|
|
name: "the same text twice is the same item to whoever reads it",
|
|
remote: "- [ ] дубль\n- [ ] дубль\n",
|
|
local: "- [ ] дубль\n- [x] дубль\n",
|
|
want: "- [x] дубль\n- [x] дубль\n",
|
|
},
|
|
{
|
|
name: "a first pull has nothing to merge",
|
|
remote: "- [ ] один\n",
|
|
local: "",
|
|
want: "- [ ] один\n",
|
|
},
|
|
{
|
|
name: "unticking is not monotone, so it does not travel",
|
|
remote: "- [x] один\n",
|
|
local: "- [ ] один\n",
|
|
want: "- [x] один\n",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := MergeCheckboxState(c.remote, c.local); got != c.want {
|
|
t.Errorf("got:\n%q\nwant:\n%q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// What `gitea:` holds is a key, and it round-trips through the one parser.
|
|
// Anything that is not a key reads as "not synced" — never as issue #0, and
|
|
// never as the issue -3 a bare strconv.Atoi would have handed back.
|
|
func TestRemoteKeyRoundTrip(t *testing.T) {
|
|
cases := []struct {
|
|
key string
|
|
repo string
|
|
number int
|
|
ok bool
|
|
}{
|
|
{"claude-skills/tea#42", "claude-skills/tea", 42, true},
|
|
{"o/r#1", "o/r", 1, true},
|
|
// Never pushed, hand-edited, or written by a format that predates this
|
|
// one — all the same answer, and none of them is issue #0. `#42` is in
|
|
// the list because a handle without a repository addresses nothing.
|
|
{"", "", 0, false},
|
|
{"claude-skills/tea", "", 0, false},
|
|
{"#42", "", 0, false},
|
|
{"o/r#", "", 0, false},
|
|
{"o/r#-3", "", 0, false},
|
|
{"o/r#4x", "", 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := RemoteKeyOf(&issue.Issue{Extra: map[string]string{GiteaKey: c.key}})
|
|
if got.Repo.String() != c.repo || got.Number != c.number || ok != c.ok {
|
|
t.Errorf("RemoteKeyOf(%q) = (%v, %v), want (%q, %d, %v)",
|
|
c.key, got, ok, c.repo, c.number, c.ok)
|
|
}
|
|
if c.ok && got.String() != c.key {
|
|
t.Errorf("the key formatted back as %q, want %q", got, c.key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNumberOf(t *testing.T) {
|
|
synced := &issue.Issue{Extra: map[string]string{GiteaKey: "o/r#42"}}
|
|
if n, ok := NumberOf(synced); n != 42 || !ok {
|
|
t.Errorf("NumberOf = (%d, %v), want (42, true)", n, ok)
|
|
}
|
|
if _, ok := NumberOf(&issue.Issue{}); ok {
|
|
t.Error("an issue that has never been pushed has no number")
|
|
}
|
|
}
|
|
|
|
func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
|
|
local := &issue.Issue{ID: "x", Origin: issue.Local}
|
|
ApplyRemote(local, &wire.Issue{
|
|
Number: 42,
|
|
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
|
|
UpdatedAt: "2026-08-09T18:24:01Z",
|
|
}, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z")
|
|
|
|
if local.IsLocal() {
|
|
t.Error("origin must move: the work exists somewhere else now")
|
|
}
|
|
if local.Extra[GiteaKey] != "o/r#42" || local.Extra[URLKey] == "" ||
|
|
local.Extra[SyncedKey] != "2026-08-11T10:00:00Z" ||
|
|
local.Extra[RemoteUpdatedKey] != "2026-08-09T18:24:01Z" {
|
|
t.Errorf("extra = %v", local.Extra)
|
|
}
|
|
}
|
|
|
|
func TestRenderComments(t *testing.T) {
|
|
got := RenderComments([]wire.Comment{
|
|
{ID: 1, User: wire.User{Login: "naudachu"}, CreatedAt: "2026-08-09T18:24:01Z", Body: " привет "},
|
|
{ID: 2, User: wire.User{Login: "bot"}, CreatedAt: "", Body: ""},
|
|
})
|
|
want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" +
|
|
"## comment 2 — bot — \n\n(empty)\n"
|
|
if got != want {
|
|
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
|
}
|
|
}
|