package cmd_test // The four commands that WRITE — comment, close, labels, sync-evict — end to // end: the real binary, in a throwaway project, against an httptest server // speaking enough of the Gitea API to answer them. // // A fake tracker rather than a mocked client, because what these commands are // trusted to get right is exactly the part a mock would stand in for: what goes // out, and what is believed about the answer. `sync-evict` deletes files on the // strength of a payload, so the payload has to come off a socket. // // Every helper here is named `wr…` so it cannot collide with the read side's. import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "testing" "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" ) const ( wrRepo = "kettle/tests" wrToken = "s3cr3t-token" wrWhen = "2026-08-11T12:00:00Z" ) // -------------------------------------------------------------------------- // the fake tracker // -------------------------------------------------------------------------- type wrLabel struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` Exclusive bool `json:"exclusive"` } type wrIssue struct { Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` State string `json:"state"` HTMLURL string `json:"html_url"` UpdatedAt string `json:"updated_at"` } type wrUser struct { Login string `json:"login"` } type wrComment struct { ID int64 `json:"id"` Body string `json:"body"` HTMLURL string `json:"html_url"` User wrUser `json:"user"` CreatedAt string `json:"created_at"` } var ( wrIssuePath = regexp.MustCompile(`^issues/(\d+)$`) wrCommentPath = regexp.MustCompile(`^issues/(\d+)/comments$`) wrLabelPath = regexp.MustCompile(`^labels/(\d+)$`) ) // wrTracker is one repository on a pretend Gitea. It records every call, so a // test can assert that a dry run sent nothing and that a second bootstrap wrote // nothing. type wrTracker struct { mu sync.Mutex labels []wrLabel issues map[int]*wrIssue comments map[int][]wrComment broken map[int]bool // numbers whose GET answers 500 calls []string next int64 } func wrNewTracker() *wrTracker { return &wrTracker{ issues: map[int]*wrIssue{}, comments: map[int][]wrComment{}, broken: map[int]bool{}, next: 100, } } func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) { tr.mu.Lock() defer tr.mu.Unlock() tr.calls = append(tr.calls, r.Method+" "+r.URL.Path) // The scheme Gitea uses and the client sends: the word `token`. if r.Header.Get("Authorization") != "token "+wrToken { http.Error(w, `{"message":"token required"}`, http.StatusUnauthorized) return } path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/"+wrRepo+"/") if !ok { http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound) return } switch { case path == "labels" && r.Method == http.MethodGet: wrJSON(w, tr.labels) case path == "labels" && r.Method == http.MethodPost: var req wrLabel wrDecode(r, &req) tr.next++ req.ID = tr.next tr.labels = append(tr.labels, req) wrJSON(w, req) case r.Method == http.MethodPatch && wrLabelPath.MatchString(path): id, _ := strconv.ParseInt(wrLabelPath.FindStringSubmatch(path)[1], 10, 64) var req wrLabel wrDecode(r, &req) for i := range tr.labels { if tr.labels[i].ID == id { req.ID = id tr.labels[i] = req wrJSON(w, req) return } } http.Error(w, `{"message":"no such label"}`, http.StatusNotFound) case wrIssuePath.MatchString(path): n, _ := strconv.Atoi(wrIssuePath.FindStringSubmatch(path)[1]) if tr.broken[n] { http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError) return } got := tr.issues[n] if got == nil { http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound) return } if r.Method == http.MethodPatch { var req struct { State *string `json:"state"` Title *string `json:"title"` } wrDecode(r, &req) // A close is state and nothing else; a title arriving here would be // the command editing an issue it was only asked to close. if req.Title != nil { http.Error(w, `{"message":"close sent a title"}`, http.StatusUnprocessableEntity) return } if req.State != nil { got.State = *req.State } got.UpdatedAt = wrWhen } wrJSON(w, got) case wrCommentPath.MatchString(path): n, _ := strconv.Atoi(wrCommentPath.FindStringSubmatch(path)[1]) if tr.issues[n] == nil { http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound) return } if r.Method == http.MethodPost { var req struct { Body string `json:"body"` } wrDecode(r, &req) tr.next++ c := wrComment{ ID: tr.next, Body: req.Body, HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d#issuecomment-%d", wrRepo, n, tr.next), User: wrUser{Login: "tester"}, CreatedAt: wrWhen, } tr.comments[n] = append(tr.comments[n], c) wrJSON(w, c) return } wrJSON(w, tr.comments[n]) default: http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound) } } func wrJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func wrDecode(r *http.Request, v any) { _ = json.NewDecoder(r.Body).Decode(v) } // --- what the tracker holds, for a test to arrange and to read back --------- func (tr *wrTracker) add(number int, title, state string) { tr.mu.Lock() defer tr.mu.Unlock() tr.issues[number] = &wrIssue{ Number: number, Title: title, State: state, HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d", wrRepo, number), UpdatedAt: wrWhen, } } func (tr *wrTracker) breaks(number int) { tr.mu.Lock() defer tr.mu.Unlock() tr.broken[number] = true } func (tr *wrTracker) state(number int) string { tr.mu.Lock() defer tr.mu.Unlock() if got := tr.issues[number]; got != nil { return got.State } return "" } func (tr *wrTracker) label(name string) *wrLabel { tr.mu.Lock() defer tr.mu.Unlock() for i := range tr.labels { if tr.labels[i].Name == name { out := tr.labels[i] return &out } } return nil } func (tr *wrTracker) labelCount() int { tr.mu.Lock() defer tr.mu.Unlock() return len(tr.labels) } func (tr *wrTracker) thread(number int) []wrComment { tr.mu.Lock() defer tr.mu.Unlock() return append([]wrComment{}, tr.comments[number]...) } // mark is where the log has got to, so a test can ask what one run sent. func (tr *wrTracker) mark() int { tr.mu.Lock() defer tr.mu.Unlock() return len(tr.calls) } func (tr *wrTracker) since(mark int) []string { tr.mu.Lock() defer tr.mu.Unlock() return append([]string{}, tr.calls[mark:]...) } func (tr *wrTracker) count(method string) int { tr.mu.Lock() defer tr.mu.Unlock() n := 0 for _, c := range tr.calls { if strings.HasPrefix(c, method+" ") { n++ } } return n } // -------------------------------------------------------------------------- // the fixture // -------------------------------------------------------------------------- // wrProject is an initialized project pointed at a fake tracker. // // The credentials arrive through the environment, which is what they are there // for — and KETTLE_CONFIG_HOME goes at a temp directory so a run can neither // read nor overwrite the developer's own tokens. KETTLE_LOGIN is cleared for the // same reason: a value in the developer's shell would send every fixture // looking for a login that is not in the temp file. func wrProject(t *testing.T) (dir string, tr *wrTracker, env []string) { t.Helper() dir = newProject(t) tr = wrNewTracker() srv := httptest.NewServer(tr) t.Cleanup(srv.Close) return dir, tr, []string{ "KETTLE_URL=" + srv.URL, "KETTLE_TOKEN=" + wrToken, "KETTLE_REPO=" + wrRepo, "KETTLE_CONFIG_HOME=" + t.TempDir(), "KETTLE_LOGIN=", } } func wrStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") } // wrTracked writes an issue the tracker also holds — a working copy, as a pull // would have left it. func wrTracked(t *testing.T, dir, id string, number int, state string) string { t.Helper() return wrWrite(t, dir, id, fmt.Sprintf( "---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+ "origin: gitea\ngitea: %s#%d\nsynced: 2026-01-01T00:00:00Z\nurl: https://tracker.example/%s/issues/%d\n---\n"+ "# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n", id, state, wrRepo, number, wrRepo, number, id)) } // wrLocal writes an `origin: local` issue — the only copy of that work. func wrLocal(t *testing.T, dir, id, state string) string { t.Helper() return wrWrite(t, dir, id, fmt.Sprintf( "---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+ "origin: local\n---\n# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n", id, state, id)) } func wrWrite(t *testing.T, dir, id, text string) string { t.Helper() path := filepath.Join(wrStore(dir), id+".md") if err := os.WriteFile(path, []byte(text), 0o644); err != nil { t.Fatal(err) } return path } func wrRead(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(raw) } func wrGone(t *testing.T, path, why string) { t.Helper() if _, err := os.Stat(path); err == nil { t.Errorf("%s is still there — %s", filepath.Base(path), why) } } func wrThere(t *testing.T, path, why string) { t.Helper() if _, err := os.Stat(path); err != nil { t.Fatalf("%s is gone — %s", filepath.Base(path), why) } } // -------------------------------------------------------------------------- // labels // -------------------------------------------------------------------------- func TestLabelsCreatesTheCanonicalSetAndThenChangesNothing(t *testing.T) { dir, tr, env := wrProject(t) r := runWith(t, dir, env, "", "labels") if r.code != 0 { t.Fatalf("labels exited %d:\n%s", r.code, r.out()) } // The set is the domain's, name for name: nothing is spelled out in the // command, so adding a type over there is what adds it here. want := issue.CanonicalLabels() if got := tr.labelCount(); got != len(want) { t.Fatalf("the repository holds %d label(s), want %d:\n%s", got, len(want), r.out()) } for _, name := range want { l := tr.label(name) if l == nil { t.Fatalf("%s was not created:\n%s", name, r.out()) } if l.Color == "" { t.Errorf("%s was created with no colour", name) } // `exclusive` is the flag no tracker CLI could set, and the whole reason // label creation goes through the API. if !l.Exclusive { t.Errorf("%s is not exclusive", name) } } if !strings.Contains(r.stdout, "created type/bug") { t.Errorf("the receipt does not name what it created:\n%s", r.stdout) } // A label belongs to the repository, not to any issue: this must not have // touched the store. if _, err := os.Stat(filepath.Join(wrStore(dir), "INDEX.md")); err == nil { t.Error("a label bootstrap wrote into the issue store") } mark := tr.mark() again := runWith(t, dir, env, "", "labels") if again.code != 0 { t.Fatalf("the second run exited %d:\n%s", again.code, again.out()) } for _, c := range tr.since(mark) { if !strings.HasPrefix(c, "GET ") { t.Errorf("the second run wrote: %s", c) } } if !strings.Contains(again.stdout, "present type/bug") || !strings.Contains(again.stdout, "0 created") { t.Errorf("a second run must be a no-op and say so:\n%s", again.stdout) } } // -------------------------------------------------------------------------- // close // -------------------------------------------------------------------------- func TestCloseChangesTheStateOnTheTrackerAndOnDisk(t *testing.T) { dir, tr, env := wrProject(t) tr.add(42, "Done and elsewhere", "open") tr.add(99, "Never seen here", "open") path := wrTracked(t, dir, "done-and-elsewhere", 42, "open") dry := runWith(t, dir, env, "", "close", "--dry-run", "done-and-elsewhere") if dry.code != 0 || !strings.Contains(dry.stdout, "would close") { t.Fatalf("the dry run said nothing:\n%s", dry.out()) } if n := tr.count("PATCH"); n != 0 { t.Errorf("a dry run sent %d write(s) — it must make no request at all", n) } if !strings.Contains(wrRead(t, path), "state: open") { t.Error("a dry run wrote to the local file") } r := runWith(t, dir, env, "", "close", "done-and-elsewhere") if r.code != 0 { t.Fatalf("close exited %d:\n%s", r.code, r.out()) } if got := tr.state(42); got != "closed" { t.Errorf("the tracker says %q, want closed", got) } local := wrRead(t, path) if !strings.Contains(local, "state: closed") { t.Errorf("the local copy was not brought along:\n%s", local) } // The answer that authorized the write is also the newest thing the tracker // has said, so the freshness fields are stamped from it. if !strings.Contains(local, "remote-updated: "+wrWhen) || !strings.Contains(local, "synced: 20") { t.Errorf("the freshness fields were not stamped:\n%s", local) } index := filepath.Join(wrStore(dir), "INDEX.md") if !strings.Contains(wrRead(t, index), "closed") { t.Error("INDEX.md was not rebuilt from what is now on disk") } // A number this machine has never seen: closed in the tracker, nothing // written here, and the receipt says which is which. byNumber := runWith(t, dir, env, "", "close", "99") if byNumber.code != 0 { t.Fatalf("closing by number exited %d:\n%s", byNumber.code, byNumber.out()) } if got := tr.state(99); got != "closed" { t.Errorf("#99 says %q, want closed", got) } if !strings.Contains(byNumber.stdout, "no local copy") { t.Errorf("the receipt hid that there was nothing to write:\n%s", byNumber.stdout) } // A number resolves through the file that carries the handle, so the local // copy of #42 is kept honest even when it was named by number. back := runWith(t, dir, env, "", "close", "--reopen", "42") if back.code != 0 { t.Fatalf("reopening by number exited %d:\n%s", back.code, back.out()) } if got := tr.state(42); got != "open" { t.Errorf("#42 says %q, want open", got) } if !strings.Contains(wrRead(t, path), "state: open") { t.Errorf("a number named the tracker but not the local copy holding its handle:\n%s", wrRead(t, path)) } // An issue that has never left this machine has no state in the tracker to // change, and saying so beats editing one field of a local file. wrLocal(t, dir, "never-left-here", "open") refused := runWith(t, dir, env, "", "close", "never-left-here") if refused.code == 0 || !strings.Contains(refused.stderr, "push") { t.Errorf("closing a local issue must stop and say why:\n%s", refused.out()) } } // -------------------------------------------------------------------------- // comment // -------------------------------------------------------------------------- func TestCommentPostsAndTheThreadLandsBesideTheIssue(t *testing.T) { dir, tr, env := wrProject(t) tr.add(42, "Talk about it", "open") wrTracked(t, dir, "talk-about-it", 42, "open") wrLocal(t, dir, "never-left-here", "open") const said = "готово, задеплоено" r := runWith(t, dir, env, "", "comment", "talk-about-it", "--body", said) if r.code != 0 { t.Fatalf("comment exited %d:\n%s", r.code, r.out()) } thread := tr.thread(42) if len(thread) != 1 || thread[0].Body != said { t.Fatalf("the tracker holds %v", thread) } sidecar := filepath.Join(wrStore(dir), "talk-about-it.comments.md") got := wrRead(t, sidecar) if !strings.Contains(got, said) || !strings.Contains(got, "## comment ") { t.Errorf("the thread did not land beside the issue:\n%s", got) } if !strings.Contains(r.stdout, "posted comment") || !strings.Contains(r.stdout, "thread:") { t.Errorf("the receipt does not say what happened:\n%s", r.stdout) } // The target is a local id resolved through the `gitea:` handle, so an issue // that carries none cannot be commented on at all. refused := runWith(t, dir, env, "", "comment", "never-left-here", "--body", "x") if refused.code == 0 || !strings.Contains(refused.stderr, "push") { t.Errorf("commenting on a local-only issue must stop and say why:\n%s", refused.out()) } if n := tr.count("POST"); n != 1 { t.Errorf("%d comment(s) went out, want 1 — the refused one was sent anyway", n) } } // -------------------------------------------------------------------------- // sync-evict // -------------------------------------------------------------------------- // The one thing this command adds to the offline evict: a `state:` that is not // stale. The file says open, the tracker says closed, and the tracker is right. func TestSyncEvictRefreshesTheStateBeforeItDecides(t *testing.T) { dir, tr, env := wrProject(t) tr.add(42, "Closed in the web ui", "closed") path := wrTracked(t, dir, "closed-in-the-web-ui", 42, "open") offline := runWith(t, dir, env, "", "evict") if offline.code != 0 || !strings.Contains(offline.stdout, "0 issue(s) evicted") { t.Fatalf("the offline evict must keep an issue whose file reads open:\n%s", offline.out()) } wrThere(t, path, "the offline evict asks the file, and the file says open") // A dry run asks, reports, and touches nothing. dry := runWith(t, dir, env, "", "sync-evict", "--dry-run") if dry.code != 0 || !strings.Contains(dry.stdout, "would evict") { t.Fatalf("the dry run said nothing:\n%s", dry.out()) } wrThere(t, path, "a dry run deleted the issue") if !strings.Contains(wrRead(t, path), "state: open") { t.Error("a dry run wrote the refreshed state to disk") } r := runWith(t, dir, env, "", "sync-evict") if r.code != 0 { t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out()) } if !strings.Contains(r.stdout, "open -> closed") { t.Errorf("the refresh was not reported:\n%s", r.stdout) } wrGone(t, path, "the tracker said it was closed") if index := wrRead(t, filepath.Join(wrStore(dir), "INDEX.md")); strings.Contains(index, "closed-in-the-web-ui") { t.Errorf("INDEX.md still lists the evicted issue:\n%s", index) } } func TestSyncEvictKeepsALocalIssueTheTrackerNeverHeardOf(t *testing.T) { dir, tr, env := wrProject(t) tr.add(42, "Done elsewhere", "closed") tracked := wrTracked(t, dir, "done-elsewhere", 42, "closed") local := wrLocal(t, dir, "only-copy-there-is", "closed") r := runWith(t, dir, env, "", "sync-evict") if r.code != 0 { t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out()) } wrThere(t, local, "a closed origin: local issue was deleted, and that file IS the work") wrGone(t, tracked, "it is closed and the tracker has it") // It was never asked about either: an issue that has never left this machine // is not a question the tracker has an answer to. if n := tr.count("GET"); n != 1 { t.Errorf("%d issue(s) were asked about, want 1", n) } // Naming it explicitly does not make deleting it safe, and the reason is // said out loud rather than left to be inferred from silence. named := runWith(t, dir, env, "", "sync-evict", "only-copy-there-is") if named.code != 0 { t.Fatalf("naming a local issue exited %d:\n%s", named.code, named.out()) } if !strings.Contains(named.stdout, "kept") || !strings.Contains(named.stdout, "IS the issue") { t.Errorf("keeping it must be said out loud:\n%s", named.out()) } wrThere(t, local, "naming it on the command line deleted it") } // A failed answer evicts NOTHING AT ALL — not even the issues whose answers had // already arrived. There is no ordering constraint between evictions, so there // is no reason to start before every answer is in. func TestATrackerFailureDuringSyncEvictEvictsNothing(t *testing.T) { dir, tr, env := wrProject(t) tr.add(42, "First answer", "closed") tr.add(43, "Second answer", "closed") tr.breaks(43) answered := wrTracked(t, dir, "aaa-answered", 42, "closed") unanswered := wrTracked(t, dir, "bbb-unanswered", 43, "closed") r := runWith(t, dir, env, "", "sync-evict") if r.code == 0 { t.Fatalf("a tracker failure must stop the run:\n%s", r.out()) } if !strings.Contains(r.stderr, "Nothing was evicted") { t.Errorf("the failure must say what it did not do:\n%s", r.stderr) } wrThere(t, answered, "its answer arrived, but another one did not") wrThere(t, unanswered, "the tracker never answered for it") }