1239fdee70
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
421 lines
15 KiB
Go
421 lines
15 KiB
Go
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)
|
|
}
|
|
}
|