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:
@@ -0,0 +1,396 @@
|
||||
package gitea_test
|
||||
|
||||
// The transport is tested against httptest, never against a tracker: a test
|
||||
// that needs a server somewhere is a test nobody runs.
|
||||
//
|
||||
// Every fixture builds a throwaway project in a temp directory and points the
|
||||
// project walk at it with CLAUDE_PROJECT_DIR. Without that the walk falls
|
||||
// through to the working directory — which during a test run is this repository
|
||||
// — and a request dump would land in the developer's own project. Nothing here
|
||||
// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same,
|
||||
// so a run can neither read nor overwrite the developer's own tokens.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// newProject makes an initialized project and points the walk at it. Returns
|
||||
// the project root.
|
||||
func newProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".kettle"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", dir)
|
||||
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
|
||||
return dir
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, url string) *gitea.Client {
|
||||
t.Helper()
|
||||
c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Errorf("encoding the fake response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A list endpoint is followed to the last page and no further: the short page
|
||||
// ends it, and the page after that is never asked for.
|
||||
func TestPaginationFollowsToTheLastPage(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
var asked []string
|
||||
var auth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
asked = append(asked, r.URL.RequestURI())
|
||||
auth = r.Header.Get("Authorization")
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
n := limit
|
||||
if page == 3 {
|
||||
n = 7 // the short page
|
||||
} else if page > 3 {
|
||||
n = 0
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"})
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newClient(t, srv.URL).ListComments(42)
|
||||
if err != nil {
|
||||
t.Fatalf("ListComments: %v", err)
|
||||
}
|
||||
if len(got) != 107 {
|
||||
t.Errorf("got %d comments, want 107 (50 + 50 + 7)", len(got))
|
||||
}
|
||||
if len(asked) != 3 {
|
||||
t.Errorf("made %d requests (%v), want 3 — a short page is the last one", len(asked), asked)
|
||||
}
|
||||
if got[0].ID != 1 || got[106].ID != 107 {
|
||||
t.Errorf("pages arrived out of order: first %d, last %d", got[0].ID, got[106].ID)
|
||||
}
|
||||
// Gitea's own scheme, and what the CLI this replaces sent.
|
||||
if auth != "token s3cret" {
|
||||
t.Errorf("Authorization was %q, want %q", auth, "token s3cret")
|
||||
}
|
||||
want := "/api/v1/repos/acme/widgets/issues/42/comments?page=1&limit=50"
|
||||
if asked[0] != want {
|
||||
t.Errorf("first request was %s, want %s", asked[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-2xx carries the status AND the body, because the status alone has never
|
||||
// told anybody which of the four things that answer 422 actually happened.
|
||||
func TestErrorNamesTheStatusAndTheBody(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := newClient(t, srv.URL).GetIssue(7)
|
||||
if err == nil {
|
||||
t.Fatal("a 422 returned no error")
|
||||
}
|
||||
var apiErr *gitea.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Status is %d, want 422", apiErr.Status)
|
||||
}
|
||||
if !gitea.StatusIs(err, http.StatusUnprocessableEntity) {
|
||||
t.Error("StatusIs did not recognize its own error")
|
||||
}
|
||||
for _, want := range []string{"422", "label already exists", "GET", "/api/v1/repos/acme/widgets/issues/7"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not mention %q:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
// The token is in a header, so quoting the URL is safe — and it had better
|
||||
// stay that way.
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Errorf("the error quotes the token:\n%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A request body is filed in the scratchpad, which is a SIBLING of the store
|
||||
// and never inside it. A call that touches no issue must not materialize an
|
||||
// issue directory.
|
||||
func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
|
||||
root := newProject(t)
|
||||
|
||||
var sent []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sent, _ = io.ReadAll(r.Body)
|
||||
writeJSON(t, w, map[string]any{
|
||||
"number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body := "<!-- kettle:id wire-sqlc --> a & b"
|
||||
got, err := newClient(t, srv.URL).CreateIssue(
|
||||
wire.IssueRequest{Title: wire.Set("wire sqlc"), Body: wire.Set(body)}, "issue-wire-sqlc")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue: %v", err)
|
||||
}
|
||||
if got.Number != 42 {
|
||||
t.Errorf("got issue #%d, want #42", got.Number)
|
||||
}
|
||||
|
||||
path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("the request body was not filed at %s: %v", path, err)
|
||||
}
|
||||
if string(raw) != string(sent) {
|
||||
t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent)
|
||||
}
|
||||
// A dump escaped to \u003c is unreadable exactly when it is being read.
|
||||
if !strings.Contains(string(raw), "<!-- kettle:id wire-sqlc --> a & b") {
|
||||
t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw)
|
||||
}
|
||||
// The whole reason the scratchpad is a sibling.
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) {
|
||||
t.Errorf("writing a request body materialized the issue store (%v)", err)
|
||||
}
|
||||
// A namespaced name must not climb out of the scratchpad.
|
||||
if _, err := newClient(t, srv.URL).CreateLabel(wire.LabelRequest{Name: "type/bug", Color: "#ee0701"}); err != nil {
|
||||
t.Fatalf("CreateLabel: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil {
|
||||
t.Errorf("a label request body was not filed under a safe name: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "type")); !os.IsNotExist(err) {
|
||||
t.Error("a label name with a slash in it made a directory inside the scratchpad")
|
||||
}
|
||||
}
|
||||
|
||||
// A run that sends no body leaves no directory behind — the scratchpad is
|
||||
// created by the first write and only then.
|
||||
func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
|
||||
root := newProject(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]any{"number": 42})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newClient(t, srv.URL).GetIssue(42); err != nil {
|
||||
t.Fatalf("GetIssue: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
||||
t.Errorf("a read created the payload directory (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The milestone filter is re-checked on the client, because Gitea silently
|
||||
// ignores one it cannot resolve and answers with the whole backlog. Pull
|
||||
// requests go the same way.
|
||||
func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/milestones") {
|
||||
writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}})
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("page") != "1" {
|
||||
writeJSON(t, w, []map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, []map[string]any{
|
||||
{"number": 1, "title": "in the milestone", "milestone": map[string]any{"id": 3, "title": "v1"}},
|
||||
{"number": 2, "title": "another milestone", "milestone": map[string]any{"id": 9, "title": "later"}},
|
||||
{"number": 3, "title": "no milestone at all"},
|
||||
{"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"},
|
||||
"pull_request": map[string]any{"merged": false}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if got.Milestone != "v1" {
|
||||
t.Errorf("resolved milestone is %q, want v1", got.Milestone)
|
||||
}
|
||||
if len(got.Issues) != 1 || got.Issues[0].Number != 1 {
|
||||
t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues)
|
||||
}
|
||||
|
||||
if _, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "typo", Limit: 50}); err == nil {
|
||||
t.Error("an unknown milestone was accepted — that reads as a milestone with the whole backlog in it")
|
||||
} else if !strings.Contains(err.Error(), "have: v1 (id 3)") {
|
||||
t.Errorf("the error does not say what the repo actually has: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Keep predicate that rejects everything must not turn a bounded read into a
|
||||
// walk of the whole tracker, and coming up short is reported rather than
|
||||
// answered in silence.
|
||||
func TestListIssuesStopsAtThePageBudget(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
pages := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pages++
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
out := []map[string]any{}
|
||||
for i := 0; i < limit; i++ {
|
||||
out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"})
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
||||
Limit: 2,
|
||||
Keep: func(i *wire.Issue) bool { return i.State == "open" },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if pages != gitea.PageSlack {
|
||||
t.Errorf("read %d page(s), want %d — one ideal page times the slack", pages, gitea.PageSlack)
|
||||
}
|
||||
if got.Warning == "" {
|
||||
t.Error("stopped short of the limit and said nothing about it")
|
||||
}
|
||||
// Everything enumerated comes back even though none of it counted: a caller
|
||||
// with something to say about the ones that did not still can.
|
||||
if len(got.Issues) != gitea.PageSlack*2 {
|
||||
t.Errorf("got %d issue(s), want every payload that was enumerated", len(got.Issues))
|
||||
}
|
||||
}
|
||||
|
||||
// A Keep-bounded read stops the moment the budget is full: the page after the
|
||||
// one that completed it is never requested.
|
||||
func TestListIssuesStopsAtTheLimit(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
pages := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pages++
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
out := []map[string]any{}
|
||||
for i := 0; i < limit; i++ {
|
||||
out = append(out, map[string]any{"number": pages*100 + i, "state": "open"})
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
||||
Limit: 2,
|
||||
Keep: func(i *wire.Issue) bool { return i.State == "open" },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if pages != 1 {
|
||||
t.Errorf("read %d page(s), want 1 — the budget was full after the first", pages)
|
||||
}
|
||||
if len(got.Issues) != 2 || got.Warning != "" {
|
||||
t.Errorf("got %d issue(s), warning %q; want 2 and no warning", len(got.Issues), got.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
// A dependency endpoint the instance does not have is "no dependencies", not a
|
||||
// failed pull. A dead connection still is one.
|
||||
func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not implemented", http.StatusNotImplemented)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := newClient(t, srv.URL).DependencyKeys(42)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err)
|
||||
}
|
||||
|
||||
srv.Close()
|
||||
if _, err := newClient(t, srv.URL).Dependencies(42); err == nil {
|
||||
t.Error("a dead connection was reported as an instance without dependency support")
|
||||
}
|
||||
}
|
||||
|
||||
// A half-filled configuration is refused here rather than at the first 401,
|
||||
// because a 401 names nothing an operator can act on.
|
||||
func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
what string
|
||||
cfg config.Resolved
|
||||
want string
|
||||
}{
|
||||
{"no url", config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
|
||||
{"no token", config.Resolved{URL: "u", Owner: "a", Repo: "b"}, "kettle auth add"},
|
||||
{"no repo", config.Resolved{URL: "u", Token: "t"}, "kettle init --repo"},
|
||||
} {
|
||||
_, err := gitea.New(&tc.cfg)
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", tc.what)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: the error does not name the fix (%q): %v", tc.what, tc.want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The layering rule, from this side. The transport knows numbers, logins, HTTP
|
||||
// and JSON; the domain knows none of those, and neither may reach the other.
|
||||
//
|
||||
// The bridge is out too, and for a reason of its own: it is the layer that
|
||||
// translates between the two, so it sits ABOVE both. A transport that imported
|
||||
// it would be a transport that knows what an issue is, one indirection later —
|
||||
// and the protocol both of them share, internal/wire, exists precisely so that
|
||||
// neither has to reach for the other to name a payload.
|
||||
func TestTransportDoesNotImportTheDomain(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
switch {
|
||||
case strings.HasSuffix(dep, "/internal/issue"):
|
||||
t.Errorf("the transport imports %s — what an issue IS is not a transport concept", dep)
|
||||
case strings.HasSuffix(dep, "/internal/mapping"):
|
||||
t.Errorf("the transport imports %s — translating is a layer of its own, and it sits above this one", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user