package mapping import ( "encoding/json" "reflect" "strings" "testing" "time" sdk "code.gitea.io/sdk/gitea" "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"} // when parses a tracker timestamp the way the SDK hands one over, so a fixture // can be written in the spelling Gitea actually sends. func when(t *testing.T, s string) time.Time { t.Helper() got, err := time.Parse(time.RFC3339, s) if err != nil { t.Fatalf("parsing %q: %v", s, err) } return got } // 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 := ToCreate(local, roundTripOptions()) if req.Title != local.Title { t.Errorf("title = %q, want %q", req.Title, local.Title) } if req.Body == "" { 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}; !reflect.DeepEqual(req.Labels, want) { t.Errorf("labels = %v, want %v", req.Labels, want) } if want := []string{"naudachu"}; !reflect.DeepEqual(req.Assignees, want) { t.Errorf("assignees = %v, want %v", req.Assignees, want) } if req.Milestone != 5 { t.Errorf("milestone = %v, want 5", req.Milestone) } if req.Ref != "feat/wire-sqlc" { t.Errorf("ref = %q — branch: is a sync field and must ride along", req.Ref) } // An edit is the other half of the same translation, and the two must not // disagree about the issue they describe. edit := ToEdit(local, roundTripOptions()) if edit.Body == nil || *edit.Body != req.Body || edit.Title != req.Title { t.Errorf("a create and an edit describe different issues: %q / %v", edit.Title, edit.Body) } if edit.State == nil || *edit.State != sdk.StateOpen { t.Errorf("state = %v", edit.State) } if edit.Ref == nil || *edit.Ref != "feat/wire-sqlc" { t.Errorf("ref = %v — branch: is a sync field and must ride along", edit.Ref) } // What the tracker hands back is the body it was given, plus its own // bookkeeping. echo := &sdk.Issue{ Index: 42, Title: req.Title, Body: req.Body, State: sdk.StateOpen, HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42", Updated: when(t, "2026-08-09T18:24:01Z"), Ref: req.Ref, Comments: 3, Labels: []*sdk.Label{{Name: "type/task"}, {Name: "tech/sql"}}, Assignees: []*sdk.User{{UserName: "naudachu"}}, Milestone: &sdk.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 := ToCreate(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. // // On an EDIT that is a statement and not an absence, which is why this asserts // on the bytes. Gitea reads a null as "no opinion" and a value as "make it // this", and the SDK's edit body sends every key — so `"assignees":null` is the // spelling that leaves the tracker's assignees alone, and `"assignees":[]` // would clear them. 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") opt := RequestOptions{LabelIDs: map[string]int64{"type/task": 11}} edit := ToEdit(local, opt) if edit.Assignees != nil { t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", edit.Assignees) } if edit.Milestone != nil { t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", edit.Milestone) } raw, err := json.Marshal(edit) if err != nil { t.Fatalf("marshal: %v", err) } body := string(raw) for _, key := range []string{`"assignees":null`, `"milestone":null`, `"state":null`, `"ref":null`} { if !strings.Contains(body, key) { t.Errorf("%s is not in the edit body; anything else there overwrites what the tracker holds: %s", key, body) } } // The title is the one field of an edit that is not a pointer. Gitea reads // an empty one as "leave it alone" too, but this issue has a title and it // has to go up. if !strings.Contains(body, `"title":"A lone issue"`) { t.Errorf("the title is missing from %s", body) } // A create is the opposite: there is nothing on the tracker's side to // overwrite, so the resolved label ids are sent as they stand. created, err := json.Marshal(ToCreate(local, opt)) if err != nil { t.Fatalf("marshal: %v", err) } if !strings.Contains(string(created), `"labels":[11]`) { t.Errorf("labels missing from %s", created) } // A resolved label set that matched nothing is still an answer, and it is // the same answer a PUT sends at an issue that already exists. if got := LabelIDsFor(local, RequestOptions{LabelIDs: map[string]int64{}}); got == nil || len(got) != 0 { t.Errorf("a resolved label set that matched nothing must be an empty list, got %v", got) } if got := LabelIDsFor(local, RequestOptions{}); got != nil { t.Errorf("a caller that resolved no ids has no opinion about labels, got %v", got) } back, unresolved := FromPayload(&sdk.Issue{ Index: 9, Title: "A lone issue", Body: WithIDMarker("## Summary\nОдин.", "lone"), State: sdk.StateOpen, 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(&sdk.Issue{Index: 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(&sdk.Issue{Index: 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, &sdk.Issue{ Index: 42, HTMLURL: "https://git.noodles.cam/o/r/issues/42", Updated: when(t, "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) } } // A thread is flattened for a reader, so it may be as lossy as a reader needs — // but a payload that carries no date and no author still renders, because a // comment that is there is worth showing whatever the tracker left out of it. func TestRenderComments(t *testing.T) { got := RenderComments([]*sdk.Comment{ {ID: 1, Poster: &sdk.User{UserName: "naudachu"}, Created: when(t, "2026-08-09T18:24:01Z"), Body: " привет "}, {ID: 2, Poster: nil, Body: ""}, }) want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" + "## comment 2 — — \n\n(empty)\n" if got != want { t.Errorf("got:\n%q\nwant:\n%q", got, want) } }