Files
marketplace/cli/internal/mapping/mapping_test.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

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)
}
}