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>
This commit is contained in:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+631
View File
@@ -0,0 +1,631 @@
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")
}