Files
marketplace/cli/internal/cmd/sync_pull_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

665 lines
21 KiB
Go

package cmd_test
// The transport, end to end: the real binary, run as a subprocess against a
// throwaway project, talking to an httptest server that speaks enough of the
// Gitea REST API to answer it.
//
// Enough and no more. What is worth proving here is not that JSON round-trips —
// internal/mapping has tests for that, without a server anywhere — but the two
// rules that cost work when they are wrong: a confirmed push takes the local file
// with it, and an unconfirmed one does not touch it.
//
// The repository is always owner/repo, and the credentials arrive through
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
// overwrite the developer's own tokens.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
type pullFakeGitea struct {
mu sync.Mutex
issues map[int]*wire.Issue
deps map[int][]int
comments map[int][]wire.Comment
labels map[string]int64
next int
// writesFail makes every issue create and edit answer 500 — the failure a
// push has to survive without losing a file.
writesFail bool
}
func pullNewGitea() *pullFakeGitea {
return &pullFakeGitea{
issues: map[int]*wire.Issue{},
deps: map[int][]int{},
comments: map[int][]wire.Comment{},
labels: map[string]int64{},
}
}
// pullAdd puts an issue in the tracker the way the web UI would: it is there
// before this project ever hears about it.
func (g *pullFakeGitea) pullAdd(p wire.Issue) {
g.mu.Lock()
defer g.mu.Unlock()
if p.State == "" {
p.State = "open"
}
p.HTMLURL = pullURL(p.Number)
g.issues[p.Number] = &p
if p.Number > g.next {
g.next = p.Number
}
}
func (g *pullFakeGitea) pullIssue(n int) wire.Issue {
g.mu.Lock()
defer g.mu.Unlock()
if p := g.issues[n]; p != nil {
return *p
}
return wire.Issue{}
}
func (g *pullFakeGitea) pullRetitle(n int, title string) {
g.mu.Lock()
defer g.mu.Unlock()
g.issues[n].Title = title
}
func (g *pullFakeGitea) pullBlocks(blocked int, blockers ...int) {
g.mu.Lock()
defer g.mu.Unlock()
g.deps[blocked] = append(g.deps[blocked], blockers...)
}
func pullURL(n int) string {
return fmt.Sprintf("https://git.example.com/owner/repo/issues/%d", n)
}
var (
pullIssueRoute = regexp.MustCompile(`^issues/(\d+)$`)
pullSubRoute = regexp.MustCompile(`^issues/(\d+)/(dependencies|comments|labels)$`)
)
func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
if !ok {
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
return
}
switch {
case path == "labels" && r.Method == http.MethodGet:
out := []wire.Label{}
for name, id := range g.labels {
out = append(out, wire.Label{ID: id, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
pullJSON(w, out)
case path == "labels" && r.Method == http.MethodPost:
var req wire.LabelRequest
pullDecode(r, &req)
id := int64(1000 + len(g.labels))
g.labels[req.Name] = id
pullJSON(w, wire.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
case path == "milestones" && r.Method == http.MethodGet:
pullJSON(w, []wire.Milestone{})
case path == "issues" && r.Method == http.MethodPost:
if g.writesFail {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
var req wire.IssueRequest
pullDecode(r, &req)
g.next++
p := &wire.Issue{
Number: g.next, Title: pullStr(req.Title), Body: pullStr(req.Body),
State: "open", HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
}
g.issues[p.Number] = p
pullJSON(w, p)
case path == "issues" && r.Method == http.MethodGet:
g.pullList(w, r)
case pullIssueRoute.MatchString(path):
n := pullNumber(pullIssueRoute, path)
p := g.issues[n]
if p == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPatch {
if g.writesFail {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
var req wire.IssueRequest
pullDecode(r, &req)
if req.Title != nil {
p.Title = *req.Title
}
if req.Body != nil {
p.Body = *req.Body
}
if req.State != nil {
p.State = *req.State
}
if req.Labels != nil {
p.Labels = g.pullLabelsFor(req.Labels)
}
}
pullJSON(w, p)
case pullSubRoute.MatchString(path):
m := pullSubRoute.FindStringSubmatch(path)
n, _ := strconv.Atoi(m[1])
switch {
case m[2] == "dependencies" && r.Method == http.MethodGet:
out := []wire.Issue{}
for _, d := range g.deps[n] {
if p := g.issues[d]; p != nil {
out = append(out, *p)
}
}
pullJSON(w, out)
case m[2] == "dependencies" && r.Method == http.MethodPost:
var req struct {
Index int `json:"index"`
}
pullDecode(r, &req)
g.deps[n] = append(g.deps[n], req.Index)
w.WriteHeader(http.StatusCreated)
case m[2] == "comments" && r.Method == http.MethodGet:
out := g.comments[n]
if out == nil {
out = []wire.Comment{}
}
pullJSON(w, out)
case m[2] == "labels" && r.Method == http.MethodPut:
var req struct {
Labels []int64 `json:"labels"`
}
pullDecode(r, &req)
g.issues[n].Labels = g.pullLabelsFor(&req.Labels)
pullJSON(w, g.issues[n].Labels)
default:
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
}
default:
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
}
}
// pullList is the filtered listing, paginated the way the client asks for it.
func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state, page, limit := q.Get("state"), 1, 50
if v, err := strconv.Atoi(q.Get("page")); err == nil && v > 0 {
page = v
}
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 {
limit = v
}
var want []string
if v := q.Get("labels"); v != "" {
want = strings.Split(v, ",")
}
numbers := make([]int, 0, len(g.issues))
for n := range g.issues {
numbers = append(numbers, n)
}
sort.Ints(numbers)
out := []wire.Issue{}
for _, n := range numbers {
p := g.issues[n]
if state != "" && state != "all" && p.State != state {
continue
}
has := map[string]bool{}
for _, l := range p.Labels {
has[l.Name] = true
}
missing := false
for _, l := range want {
missing = missing || !has[l]
}
if missing {
continue
}
out = append(out, *p)
}
start := (page - 1) * limit
if start > len(out) {
start = len(out)
}
end := start + limit
if end > len(out) {
end = len(out)
}
pullJSON(w, out[start:end])
}
func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
if ids == nil {
return nil
}
byID := map[int64]string{}
for name, id := range g.labels {
byID[id] = name
}
var out []wire.Label
for _, id := range *ids {
if name, ok := byID[id]; ok {
out = append(out, wire.Label{ID: id, Name: name})
}
}
return out
}
func pullNumber(re *regexp.Regexp, path string) int {
n, _ := strconv.Atoi(re.FindStringSubmatch(path)[1])
return n
}
func pullDecode(r *http.Request, into any) {
_ = json.NewDecoder(r.Body).Decode(into)
}
func pullJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func pullStr(p *string) string {
if p == nil {
return ""
}
return *p
}
// pullEnv starts the fake and returns the environment that points the binary at
// it. The credential home is a temp directory: a test run may neither read nor
// overwrite the developer's own tokens.
func pullEnv(t *testing.T, g *pullFakeGitea) []string {
t.Helper()
srv := httptest.NewServer(g)
t.Cleanup(srv.Close)
return []string{
config.EnvURL + "=" + srv.URL,
config.EnvToken + "=t0ken",
config.EnvRepo + "=owner/repo",
config.EnvHome + "=" + t.TempDir(),
}
}
func pullStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
func pullRead(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func pullExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// --------------------------------------------------------------------------
// push
// --------------------------------------------------------------------------
// The rule the whole design rests on: once the tracker has the issue, the
// tracker IS the issue, and the local copy goes — sidecars included.
func TestPushCreatesTheIssueAndTakesTheLocalCopyWithIt(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
store := pullStore(dir)
sidecar := filepath.Join(store, id+".comments.md")
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
t.Fatal(err)
}
r := runWith(t, dir, env, "", "push")
if r.code != 0 {
t.Fatalf("push exited %d:\n%s", r.code, r.out())
}
// The number and the URL lead: in a moment they are the only address the
// issue has.
if !strings.Contains(r.stdout, "created "+id+" #1 "+pullURL(1)) {
t.Errorf("the receipt does not say where the issue lives now:\n%s", r.stdout)
}
if pullExists(filepath.Join(store, id+".md")) {
t.Error("the local file survived a confirmed push — what is in the store is what has not left")
}
if pullExists(sidecar) {
t.Error("the sidecar was left behind; every file under the slug goes")
}
// The ledger is what makes the slug come back, so it has to hold the number.
ledger := pullRead(t, filepath.Join(store, ".remote.json"))
if !strings.Contains(ledger, `"owner/repo#1": "`+id+`"`) {
t.Errorf("the ledger does not index the number:\n%s", ledger)
}
// And the slug travelled up in the body, which is what survives a lost ledger.
if body := g.pullIssue(1).Body; !strings.Contains(body, "<!-- kettle:id "+id+" -->") {
t.Errorf("the id marker did not go up with the issue:\n%s", body)
}
if !strings.HasPrefix(g.pullIssue(1).Body, "<!-- kettle:id") {
t.Error("the marker must be the first line of the tracker-side body")
}
}
// Network down, non-2xx, an answer that does not confirm the write: the file
// stays and the run stops. Nothing is deleted that was not just accepted.
func TestPushLeavesTheFileWhenTheTrackerRefuses(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.writesFail = true
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Never made it up")
const id = "never-made-it-up"
path := filepath.Join(pullStore(dir), id+".md")
before := pullRead(t, path)
r := runWith(t, dir, env, "", "push")
if r.code == 0 {
t.Fatalf("a tracker that refuses the write must fail the run:\n%s", r.out())
}
if after := pullRead(t, path); after != before {
t.Errorf("the file was touched by a push that never landed:\n%s", after)
}
// The message has to name the file, because "is my only copy still there" is
// the question an operator has at that moment.
if !strings.Contains(r.stderr, path) {
t.Errorf("the failure does not name the file it did not touch:\n%s", r.stderr)
}
if pullExists(filepath.Join(pullStore(dir), ".remote.json")) {
t.Error("a ledger entry was written for an issue the tracker never confirmed")
}
}
// --------------------------------------------------------------------------
// pull
// --------------------------------------------------------------------------
// A number is an address, not a query. Only filter mode leaves closed issues out.
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{
Number: 7, Title: "Closed but addressable", State: "closed",
Body: "## Summary\nДело сделано.\n", UpdatedAt: "2026-08-01T10:00:00Z",
})
env := pullEnv(t, g)
r := runWith(t, dir, env, "", "pull", "7")
if r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
file := pullRead(t, filepath.Join(pullStore(dir), "closed-but-addressable.md"))
if !strings.Contains(file, "state: closed") {
t.Errorf("the closed state did not land on disk:\n%s", file)
}
if !strings.Contains(file, "gitea: owner/repo#7") {
t.Errorf("the cross-repo handle is missing:\n%s", file)
}
if !strings.Contains(file, "origin: gitea") {
t.Errorf("the issue does not say it exists elsewhere:\n%s", file)
}
}
// A pull answers with the unit of work — the issue and what blocks it — and
// --no-deps is how you ask for one row of it.
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
g := pullNewGitea()
g.pullAdd(wire.Issue{Number: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
g.pullAdd(wire.Issue{Number: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
g.pullBlocks(2, 1)
env := pullEnv(t, g)
t.Run("by default", func(t *testing.T) {
dir := newProject(t)
if r := runWith(t, dir, env, "", "pull", "2"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
t.Fatal("the blocker did not come down — a pull returns the unit of work")
}
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
if !strings.Contains(dependent, "depends: [migrate-the-schema]") {
t.Errorf("depends: was not filled from the tracker's own graph:\n%s", dependent)
}
})
t.Run("--no-deps", func(t *testing.T) {
dir := newProject(t)
if r := runWith(t, dir, env, "", "pull", "2", "--no-deps"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
t.Error("--no-deps followed a blocker anyway")
}
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
if !strings.Contains(dependent, "depends: []") {
t.Errorf("--no-deps filled depends: anyway:\n%s", dependent)
}
})
}
// The round trip, and the two things that carry the slug through it: the ledger,
// and — when the ledger is gone, as it is in a fresh clone — the marker in the
// body. A rename in the web UI changes neither.
func TestAPushedIssueComesBackUnderItsOriginalSlug(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
store := pullStore(dir)
if r := runWith(t, dir, env, "", "push"); r.code != 0 {
t.Fatalf("push exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(store, id+".md")) {
t.Fatal("push did not drop the local copy")
}
g.pullRetitle(1, "Somebody retitled this in the web UI")
// The ledger knows the number, so it wins.
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
file := pullRead(t, filepath.Join(store, id+".md"))
if !strings.Contains(file, "# Somebody retitled this in the web UI") {
t.Errorf("the new title did not come down:\n%s", file)
}
// The marker is transport bookkeeping and never reaches the store.
if strings.Contains(file, "kettle:id") {
t.Errorf("the id marker was written into the local file:\n%s", file)
}
// Now lose both the file and the ledger, the way a fresh clone has neither.
// The marker in the body is all that is left, and it is enough.
for _, p := range []string{filepath.Join(store, id+".md"), filepath.Join(store, ".remote.json")} {
if err := os.Remove(p); err != nil {
t.Fatal(err)
}
}
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(store, id+".md")) {
names, _ := os.ReadDir(store)
var have []string
for _, e := range names {
have = append(have, e.Name())
}
t.Fatalf("the issue came back under another name — every depends: pointing at it now "+
"dangles; the store holds: %s", strings.Join(have, ", "))
}
}
// A closed issue is not a unit of work, so a FILTER enumerates it and leaves it
// out — the exact opposite of what a key does, and only --state closed changes
// it.
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
g := pullNewGitea()
bug := []wire.Label{{ID: 1, Name: "type/bug"}}
g.pullAdd(wire.Issue{Number: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
g.pullAdd(wire.Issue{Number: 2, Title: "Fixed last week", State: "closed",
Body: "## Summary\nx\n", Labels: bug})
env := pullEnv(t, g)
dir := newProject(t)
r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "all")
if r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
t.Error("a filter stored a closed issue")
}
// Nothing is dropped in silence.
if !strings.Contains(r.stderr, "1 closed issue(s) enumerated, not stored") {
t.Errorf("the closed issue went out without a word:\n%s", r.stderr)
}
// Naming the state is how you ask for one.
if r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "closed"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
t.Error("--state closed did not store the closed issue")
}
}
// ONE RULE, NO EXCEPTION: a PATCH is a push, and it drops the local copy too.
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{
Number: 3, Title: "Came down and went back up",
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
Labels: []wire.Label{{ID: 1, Name: "type/task"}},
})
env := pullEnv(t, g)
if r := runWith(t, dir, env, "", "pull", "3"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
const id = "came-down-and-went-back-up"
path := filepath.Join(pullStore(dir), id+".md")
if !pullExists(path) {
t.Fatal("the issue did not arrive")
}
r := runWith(t, dir, env, "", "push", "--update", id)
if r.code != 0 {
t.Fatalf("push --update exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "updated "+id+" #3") {
t.Errorf("the receipt does not report the PATCH:\n%s", r.stdout)
}
if pullExists(path) {
t.Error("--update kept the local file — two rules would put back the question " +
"push exists to remove")
}
}
// A dry run makes no request, so it must not need a credential to say what it
// would do — no URL, no token, no repository in the environment at all.
func TestPushDryRunNeedsNoCredential(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Planned but not sent")
r := run(t, dir, "push", "--dry-run")
if r.code != 0 {
t.Fatalf("a dry run must not need a tracker:\n%s", r.out())
}
if !strings.Contains(r.stdout, "ok planned-but-not-sent") ||
!strings.Contains(r.stdout, "1 issue(s) would be created") {
t.Errorf("the plan was not printed:\n%s", r.stdout)
}
if !pullExists(filepath.Join(pullStore(dir), "planned-but-not-sent.md")) {
t.Error("a dry run deleted the issue")
}
}
// --------------------------------------------------------------------------
// remote
// --------------------------------------------------------------------------
// Discovery writes nothing: the store is a store, not a search-results folder.
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{Number: 4, Title: "Something open", Body: "x"})
g.pullAdd(wire.Issue{Number: 5, Title: "Something closed", State: "closed", Body: "x"})
env := pullEnv(t, g)
r := runWith(t, dir, env, "", "remote")
if r.code != 0 {
t.Fatalf("remote exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "#4") || strings.Contains(r.stdout, "#5") {
t.Errorf("the default listing is the open issues:\n%s", r.stdout)
}
if entries, err := os.ReadDir(pullStore(dir)); err != nil || len(entries) != 0 {
t.Errorf("a listing left files in the store: %v", entries)
}
// A number the store already knows about says so, so it is obvious what a
// pull would refresh and what it would add.
if r := runWith(t, dir, env, "", "pull", "4"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
again := runWith(t, dir, env, "", "remote")
if !strings.Contains(again.stdout, "└─ local: something-open") {
t.Errorf("the local slug was not reported:\n%s", again.stdout)
}
}