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
+381
View File
@@ -0,0 +1,381 @@
// Package gitea is the transport: everything that talks to a tracker, and
// nothing else.
//
// It knows numbers, logins, HTTP verbs, pagination and JSON. It does not know
// what an issue IS — no sections, no acceptance criteria, no type taxonomy —
// and the import graph says so in both directions: this package may not reach
// into internal/issue, and internal/issue may not reach in here. A tracker
// number is not a domain concept and a checkbox is not a transport one.
// Translating between the two is a layer of its own — internal/mapping — and
// that layer is not imported here either: it sits above this package, not
// beside it.
//
// The JSON shapes and the issue keys are internal/wire's. They are not this
// package's to own, because the bridge needs exactly the same vocabulary and
// cannot import a transport to get it; a copy on each side is two structs that
// drift and a command that copies fields between them by hand.
//
// Every request goes through Call. One place sets the header, one place reads
// a status code, one place files the request body. When this was a Python
// module shelling out to `tea api`, "why did that fail" meant reading a
// subprocess's stderr and guessing; here a failure is an *APIError carrying the
// status AND the body the server actually sent, because "500" on its own has
// never helped anybody.
package gitea
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
const (
// apiPrefix is where every Gitea instance puts its REST API.
apiPrefix = "/api/v1"
// userAgent names this binary in the server's log. A tracker admin looking
// at a burst of requests should be able to tell what made them.
userAgent = "kettle"
// requestTimeout bounds a single call. A hung tracker must not hang a push
// half way through a set of issues.
requestTimeout = 30 * time.Second
// maxErrorBody caps what an error quotes back. A server having a bad day
// answers with an HTML page, and an error message is not a place to paste
// one.
maxErrorBody = 2000
)
// Client talks to one repository on one Gitea instance.
type Client struct {
// HTTP is the transport, exported so a caller can change the timeout or
// hand in an instrumented one. Never nil after New.
HTTP *http.Client
base string // instance URL with the API prefix, no trailing slash
token string
repo wire.Repo
// payloadRoot is resolved once, by New, and is never taken from a caller.
// The one time where a request body lands was an argument, it got pointed
// at the issue store — see writePayload.
payloadRoot string
}
// New builds a client for the repository this project points at.
//
// It refuses a half-filled configuration instead of letting the first call come
// back 401 or 404: those answers name nothing an operator can act on, and every
// field missing here has exactly one command that supplies it.
func New(cfg *config.Resolved) (*Client, error) {
if cfg == nil {
return nil, errors.New("no resolved configuration — call config.Require first")
}
var missing []string
if cfg.URL == "" {
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+config.EnvURL+")")
}
if cfg.Token == "" {
missing = append(missing, "a token (`kettle auth add`, or set "+config.EnvToken+")")
}
if cfg.Owner == "" || cfg.Repo == "" {
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+config.EnvRepo+")")
}
if len(missing) > 0 {
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
}
return &Client{
HTTP: &http.Client{Timeout: requestTimeout},
base: strings.TrimRight(cfg.URL, "/") + apiPrefix,
token: cfg.Token,
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
payloadRoot: project.PayloadRoot(""),
}, nil
}
// Repo is the repository every path is built against.
func (c *Client) Repo() wire.Repo { return c.repo }
// For returns a copy of this client pointed at another repository, for the run
// that was given an explicit owner/name. The credentials and the scratchpad
// come along; only the paths change.
func (c *Client) For(r wire.Repo) *Client {
out := *c
out.repo = r
return &out
}
// Body is a request payload and the name its dump is filed under.
//
// The name is the caller's label for this call, not a path: it becomes
// `<name>.json` in the scratchpad, and something that identifies the call in a
// post-mortem — an issue's slug, a label's name — is worth more there than a
// serial number.
type Body struct {
Name string
Data any
}
// Call makes one request and decodes the answer into out, which may be nil when
// there is nothing to read.
//
// body may be nil. When it is not, its Data is marshalled once: the bytes filed
// in the scratchpad and the bytes on the wire are the same bytes, so a retry
// from the file sends what this call sent.
//
// An empty response body leaves out untouched — a 204 from a PATCH is a
// success, not a decode failure.
func (c *Client) Call(method, path string, body *Body, out any) error {
var payload []byte
if body != nil {
var err error
if payload, err = c.writePayload(body); err != nil {
return err
}
}
endpoint := c.base + "/" + strings.TrimLeft(path, "/")
var reader io.Reader
if payload != nil {
reader = bytes.NewReader(payload)
}
req, err := http.NewRequest(method, endpoint, reader)
if err != nil {
return fmt.Errorf("%s %s: %w", method, endpoint, err)
}
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
// The token travels in a header and never in the URL, so an error is
// free to quote the URL in full.
return fmt.Errorf("%s %s: %w", method, endpoint, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%s %s: reading the response: %w", method, endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(raw)}
}
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected (%w): %s",
method, endpoint, resp.StatusCode, err, truncate(string(raw)))
}
return nil
}
// APIError is a non-2xx answer, carrying both halves of what happened.
//
// The status on its own is not a diagnosis. Gitea answers 422 for a label that
// already exists, for a milestone id that belongs to another repository, and
// for a body missing a field, and the three are told apart only by the message
// sent with them — so the body travels with the code, always.
type APIError struct {
Method string
URL string
Status int
Body string
}
func (e *APIError) Error() string {
body := strings.TrimSpace(e.Body)
if body == "" {
body = "(the response body was empty)"
} else {
body = truncate(body)
}
status := http.StatusText(e.Status)
if status != "" {
status = " " + status
}
return fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
}
// StatusIs reports whether err is an API answer with this status code, for the
// handful of places where one code means something specific — a 409 from a
// dependency link that is already there, say.
func StatusIs(err error, status int) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.Status == status
}
func truncate(s string) string {
if len(s) <= maxErrorBody {
return s
}
cut := s[:maxErrorBody]
// Never split a rune: a truncated message that ends in a broken byte is a
// message a terminal renders as garbage.
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return fmt.Sprintf("%s… (%d bytes total)", cut, len(s))
}
// --------------------------------------------------------------------------
// where request bodies land
// --------------------------------------------------------------------------
// writePayload marshals a request body, files a copy under `.kettle/payload/`,
// and returns the bytes to send.
//
// The file survives the call, for a retry or a post-mortem.
//
// WHERE IT LANDS IS NOT THE CALLER'S BUSINESS, and never was. The directory is
// this package's scratchpad — a SIBLING of the issue store under the same
// marker, resolved by the same walk, so which command wrote a body cannot
// change where it went and the two can never end up in different projects. The
// one time it was a caller's argument it got pointed at the store, and a label
// bootstrap that touches no issue at all materialized an issue directory on a
// fresh checkout: store contents are the thing being tracked, request bodies
// are debris of the transport, and when they share a path `ls` starts lying
// about what the project holds.
//
// It is created lazily, by the first write of a run and only then, so a dry run
// or a run with nothing to send leaves no directory behind.
func (c *Client) writePayload(b *Body) ([]byte, error) {
if c.payloadRoot == "" {
return nil, project.NotFoundError("")
}
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetIndent("", " ")
// An issue body carries `<!-- … -->` markers and prose full of `&`.
// Escaping those to < would make the dump unreadable exactly when
// somebody is reading it because something went wrong.
enc.SetEscapeHTML(false)
if err := enc.Encode(b.Data); err != nil {
return nil, fmt.Errorf("encoding the %s request body: %w", b.name(), err)
}
raw := buf.Bytes()
if err := os.MkdirAll(c.payloadRoot, 0o755); err != nil {
return nil, err
}
path := filepath.Join(c.payloadRoot, b.name()+".json")
if err := os.WriteFile(path, raw, 0o644); err != nil {
return nil, err
}
return raw, nil
}
// name is the file stem, with everything that is not plainly a file name folded
// away.
//
// Sanitizing here rather than trusting callers: label names are namespaced
// (`type/bug`), and a name passed straight through would write outside the
// scratchpad — which is the one thing this directory exists to prevent.
func (b *Body) name() string {
if b.Name == "" {
return "request"
}
safe := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
}
return '-'
}, b.Name)
if safe = strings.Trim(safe, "-"); safe == "" {
return "request"
}
return safe
}
// --------------------------------------------------------------------------
// pagination
// --------------------------------------------------------------------------
const (
// pageLimit is how many rows a list request asks for at a time. Gitea's own
// default is smaller and its maximum is larger; 50 is what the Python this
// replaces used and what the page-budget arithmetic is written against.
pageLimit = 50
// maxPages bounds any single listing. A tracker with a runaway number of
// rows must not turn one command into an unbounded read.
maxPages = 40
// PageSlack is how far past the ideal page count a Keep-bounded listing may
// scan before it gives up. The ideal is what Limit would need if every
// payload counted; the slack pays for the ones that do not. Deliberately
// small: "fetch until N are kept" without a bound is "fetch the whole
// tracker" on any repository whose filter matches mostly closed issues.
PageSlack = 4
)
// pages GETs a list endpoint page by page and hands each page to each as it
// arrives, stopping when each returns false, when a short page says the list is
// exhausted, or when budget pages have been read.
//
// A callback rather than a slice, because a caller whose budget is spent on
// what it KEEPS cannot be served by a function that fetches everything first:
// the page after the one that completed the budget must never be requested.
func pages[T any](c *Client, path string, limit, budget int, each func([]T) (bool, error)) error {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
for page := 1; page <= budget; page++ {
var batch []T
if err := c.Call(http.MethodGet, fmt.Sprintf("%s%spage=%d&limit=%d", path, sep, page, limit), nil, &batch); err != nil {
return err
}
if len(batch) == 0 {
return nil
}
more, err := each(batch)
if err != nil || !more {
return err
}
if len(batch) < limit {
return nil // a short page is the last one
}
}
return nil
}
// paginate follows a list endpoint to exhaustion and returns the whole list.
func paginate[T any](c *Client, path string, limit int) ([]T, error) {
var out []T
err := pages(c, path, limit, maxPages, func(batch []T) (bool, error) {
out = append(out, batch...)
return true, nil
})
return out, err
}
// repoPath builds an endpoint under this client's repository. Owner and name
// are escaped: they arrive from a config file, and a file is a thing people
// type into.
func (c *Client) repoPath(suffix string) string {
return "repos/" + url.PathEscape(c.repo.Owner) + "/" + url.PathEscape(c.repo.Name) + "/" + suffix
}
// repoPathf is repoPath with the issue or label number formatted in.
func (c *Client) repoPathf(format string, args ...any) string {
return c.repoPath(fmt.Sprintf(format, args...))
}
+396
View File
@@ -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)
}
}
}
+332
View File
@@ -0,0 +1,332 @@
package gitea
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// GetIssue fetches one issue by number.
//
// A number is an address, not a query: this answers for a closed issue exactly
// as it does for an open one.
func (c *Client) GetIssue(number int) (*wire.Issue, error) {
var got wire.Issue
if err := c.Call(http.MethodGet, c.repoPathf("issues/%d", number), nil, &got); err != nil {
return nil, err
}
// A 200 that carries no number is not this issue. Gitea has answered that
// way for a repository whose issue tracker is disabled.
if got.Number == 0 {
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
}
return &got, nil
}
// CreateIssue files a new issue. name labels the request body in the
// scratchpad; the issue's slug is what makes that dump worth keeping.
func (c *Client) CreateIssue(req wire.IssueRequest, name string) (*wire.Issue, error) {
var got wire.Issue
body := &Body{Name: name, Data: req}
if err := c.Call(http.MethodPost, c.repoPath("issues"), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// EditIssue patches an existing issue. Only the fields set on req are sent.
func (c *Client) EditIssue(number int, req wire.IssueRequest, name string) (*wire.Issue, error) {
var got wire.Issue
body := &Body{Name: name, Data: req}
if err := c.Call(http.MethodPatch, c.repoPathf("issues/%d", number), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// SetLabels replaces an issue's labels with exactly these ids.
//
// It exists because Gitea occasionally drops labels handed to it on create, and
// the answer to that is to re-apply them rather than to trust the echo.
func (c *Client) SetLabels(number int, ids []int64, name string) ([]wire.Label, error) {
if ids == nil {
ids = []int64{}
}
var got []wire.Label
body := &Body{Name: name, Data: struct {
Labels []int64 `json:"labels"`
}{ids}}
if err := c.Call(http.MethodPut, c.repoPathf("issues/%d/labels", number), body, &got); err != nil {
return nil, err
}
return got, nil
}
// ListComments is an issue's whole thread, every page of it.
func (c *Client) ListComments(number int) ([]wire.Comment, error) {
return paginate[wire.Comment](c, c.repoPathf("issues/%d/comments", number), pageLimit)
}
// CreateComment posts a comment on an issue.
func (c *Client) CreateComment(number int, text, name string) (*wire.Comment, error) {
var got wire.Comment
body := &Body{Name: name, Data: commentBody{Body: text}}
if err := c.Call(http.MethodPost, c.repoPathf("issues/%d/comments", number), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// EditComment rewrites one comment, addressed by its own id and not by the
// issue it is on — which is how Gitea addresses it.
func (c *Client) EditComment(id int64, text, name string) (*wire.Comment, error) {
var got wire.Comment
body := &Body{Name: name, Data: commentBody{Body: text}}
if err := c.Call(http.MethodPatch, c.repoPathf("issues/comments/%d", id), body, &got); err != nil {
return nil, err
}
return &got, nil
}
type commentBody struct {
Body string `json:"body"`
}
// --------------------------------------------------------------------------
// listing, and the filter the server does not honour
// --------------------------------------------------------------------------
// IssueFilter is what a listing asks for.
type IssueFilter struct {
// State is open (the default), closed, or all.
State string
// Labels are label names; an issue must carry all of them.
Labels []string
// Query is Gitea's keyword search over title and body.
Query string
// Milestone is an id or a title. It is resolved against the repository
// before it is trusted — see ResolveMilestone.
Milestone string
// Limit counts the payloads the CALLER cares about, not the ones the server
// returned. Must be 1 or more.
Limit int
// Keep says whether a payload counts against Limit. Without it every
// payload counts and a listing behaves as any other. With it, pages keep
// coming until Limit have counted, and the returned list carries the ones
// that did not count too — they were enumerated, and a caller with
// something to say about them ("11 closed, not stored") still can.
//
// What Keep means is the caller's business; this package only counts.
Keep func(*wire.Issue) bool
}
// IssueListing is what a filtered read found.
type IssueListing struct {
// Issues are every payload that passed the filter, kept or not.
Issues []wire.Issue
// Milestone is the resolved milestone title, for a receipt.
Milestone string
// Warning is set when a Keep-bounded read ran out of page budget with the
// budget unfilled. Returned rather than printed: the transport does not own
// the operator's terminal, and a caller that is rendering JSON needs it as
// data.
Warning string
}
// ListIssues reads filtered issue payloads.
//
// One request per page, and a payload already carries the issue body — a whole
// milestone costs one call per page, not one per issue.
//
// Two boundaries hold whatever Keep decides:
//
// - Stop at the limit. The page after the one that completed the budget is
// never requested.
// - Stop at the page budget. A predicate that rejects everything must not turn
// a bounded read into a walk of the whole tracker, so a filtered read scans
// at most PageSlack times the pages Limit would need if every payload
// counted. Hitting that with the budget unfilled sets Warning rather than
// answering short in silence: the caller asked for N and is told it got
// fewer.
func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
if f.Limit < 1 {
return nil, fmt.Errorf("a listing limit must be 1 or more, got %d", f.Limit)
}
out := &IssueListing{}
var milestoneID int64
if f.Milestone != "" {
ms, err := c.ResolveMilestone(f.Milestone)
if err != nil {
return nil, err
}
milestoneID, out.Milestone = ms.ID, ms.Title
}
params := url.Values{}
state := f.State
if state == "" {
state = "open"
}
params.Set("state", state)
params.Set("type", "issues")
if len(f.Labels) > 0 {
params.Set("labels", strings.Join(f.Labels, ","))
}
if f.Query != "" {
params.Set("q", f.Query)
}
if out.Milestone != "" {
params.Set("milestones", out.Milestone)
}
path := c.repoPath("issues?" + params.Encode())
perPage := min(f.Limit, pageLimit)
ideal := max(1, (f.Limit+perPage-1)/perPage)
budget := ideal
if f.Keep != nil {
budget = ideal * PageSlack
}
kept, seen, lastFull := 0, 0, false
err := pages(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) {
seen++
lastFull = len(batch) == perPage
for i := range batch {
p := &batch[i]
if !matches(p, milestoneID, f.Labels) {
continue
}
out.Issues = append(out.Issues, *p)
if f.Keep == nil || f.Keep(p) {
kept++
if kept >= f.Limit {
return false, nil
}
}
}
return true, nil
})
if err != nil {
return nil, err
}
if f.Keep != nil && seen >= budget && lastFull {
out.Warning = fmt.Sprintf("scanned %d page(s) and stopped %d short of the limit of %d"+
" — there may be more; narrow the filter or raise the limit", budget, f.Limit-kept, f.Limit)
}
return out, nil
}
// matches re-checks on the client what the server was already asked for.
//
// Not paranoia: Gitea silently IGNORES a `milestones=` value it cannot resolve
// and answers with the whole backlog, which is why the milestone is resolved to
// an id first and every payload is checked against that id here. The same
// re-check on labels costs nothing, and `pull_request` is the one filter that
// matters most — a pull request rendered as a unit of work is not a bug the
// operator can see until it is in the store.
//
// A function and not a method: the payload is the protocol's, and re-checking a
// filter the server ignored is this package's business, not the protocol's.
func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
if i.IsPullRequest() {
return false
}
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
return false
}
have := make(map[string]bool, len(i.Labels))
for _, l := range i.Labels {
have[l.Name] = true
}
for _, want := range labels {
if !have[want] {
return false
}
}
return true
}
// --------------------------------------------------------------------------
// dependencies
// --------------------------------------------------------------------------
// issueMeta is Gitea's IssueMeta: how a dependency names another issue.
type issueMeta struct {
Index int `json:"index"`
Owner string `json:"owner"`
Repo string `json:"repo"`
}
// Dependencies are the issues that block this one — Gitea's own dependency
// links, read in the direction AddDependency writes them.
//
// An instance that does not have the endpoint, or has dependencies turned off
// for this repository, answers with a status rather than a list. That is
// reported as "no dependencies" and not as a failure: a pull must still bring
// the issue itself back from a tracker whose dependency support is off.
//
// Deliberately narrower than the Python it replaces, which swallowed every
// failure here including a dead connection. "The server said no" and "there was
// no server" are different answers, and only the first one means the feature is
// missing.
func (c *Client) Dependencies(number int) ([]wire.Issue, error) {
var got []wire.Issue
err := c.Call(http.MethodGet, c.repoPathf("issues/%d/dependencies", number), nil, &got)
var apiErr *APIError
if errors.As(err, &apiErr) {
return nil, nil
}
if err != nil {
return nil, err
}
return got, nil
}
// DependencyKeys is the same links as cross-repo handles — what a repeat push
// compares against so it does not POST a link the tracker already has.
//
// A bare number is ambiguous the moment a dependency lives in another
// repository, and Gitea lets it, so the repository travels with it.
func (c *Client) DependencyKeys(number int) ([]wire.Key, error) {
deps, err := c.Dependencies(number)
if err != nil {
return nil, err
}
out := make([]wire.Key, 0, len(deps))
for i := range deps {
out = append(out, deps[i].KeyIn(c.repo))
}
return out, nil
}
// AddDependency makes issue number depend on dep.
//
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
//
// POST /repos/{owner}/{repo}/issues/{index}/dependencies
// body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
// "Make the issue in the url depend on the issue in the form."
//
// So the URL names the blocked issue and the body the blocker, which is the
// direction Dependencies reads back. A link that already exists answers 409, so
// callers pre-filter with DependencyKeys and treat a failure here as a note
// rather than an abort: one missing cross-link must not undo a push that has
// already created issues.
func (c *Client) AddDependency(number int, dep wire.Key) error {
if dep.Repo.Zero() {
return fmt.Errorf("dependency %s names no repository — a link needs owner/repo#number", dep)
}
if dep.Number < 1 {
return fmt.Errorf("dependency %s names no issue number", dep)
}
body := &Body{
Name: fmt.Sprintf("dep-%d-%d", number, dep.Number),
Data: issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
}
return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil)
}
+108
View File
@@ -0,0 +1,108 @@
package gitea
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// ListLabels is every label in the repository, every page of it.
//
// A bootstrap decides its plan against this and never against a cache: a cache
// answers "what did we create last time", and the question is "what does the
// repository have right now".
func (c *Client) ListLabels() ([]wire.Label, error) {
return paginate[wire.Label](c, c.repoPath("labels"), 100)
}
// CreateLabel adds a label to the repository.
//
// Through the API rather than through any CLI wrapper, because `exclusive` —
// the flag that makes `type/*` behave like a single choice — is not something
// the `tea` client could set.
//
// What a label MEANS is not decided here either: this creates what it is
// handed.
func (c *Client) CreateLabel(req wire.LabelRequest) (*wire.Label, error) {
var got wire.Label
body := &Body{Name: "label-" + req.Name, Data: req}
if err := c.Call(http.MethodPost, c.repoPath("labels"), body, &got); err != nil {
return nil, err
}
if got.ID == 0 {
return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", req.Name)
}
return &got, nil
}
// EditLabel patches an existing label by id.
func (c *Client) EditLabel(id int64, req wire.LabelRequest) (*wire.Label, error) {
var got wire.Label
body := &Body{Name: "label-" + req.Name, Data: req}
if err := c.Call(http.MethodPatch, c.repoPathf("labels/%d", id), body, &got); err != nil {
return nil, err
}
return &got, nil
}
// ListMilestones is every milestone in the repository, open and closed.
//
// Both states, always: a milestone is closed the moment its work is done, and a
// listing that hid those would fail to resolve exactly the filter somebody
// types when they want to see what was in it.
func (c *Client) ListMilestones() ([]wire.Milestone, error) {
return paginate[wire.Milestone](c, c.repoPath("milestones?state=all"), 100)
}
// ResolveMilestone finds a milestone by id or by title, and fails when there is
// none.
//
// It fails LOUDLY, and that is the whole point of resolving before filtering:
// Gitea silently ignores a `milestones=` filter it cannot resolve and answers
// with the entire backlog. A typo in a milestone name would otherwise read as
// "your milestone has 300 issues in it".
func (c *Client) ResolveMilestone(value string) (*wire.Milestone, error) {
got, err := c.ListMilestones()
if err != nil {
return nil, err
}
for i := range got {
if got[i].Title == value || strconv.FormatInt(got[i].ID, 10) == value {
return &got[i], nil
}
}
have := make([]string, 0, len(got))
for _, m := range got {
have = append(have, fmt.Sprintf("%s (id %d)", m.Title, m.ID))
}
if len(have) == 0 {
have = []string{"none"}
}
return nil, fmt.Errorf("no milestone %q in %s — have: %s", value, c.repo, strings.Join(have, ", "))
}
// FindMilestone is the milestone with this title, or nil when the repository
// has no such milestone.
//
// The quiet counterpart of ResolveMilestone, for a push: an issue naming a
// milestone the tracker does not have is filed without one, because refusing
// the whole push over a field the tracker will happily accept as empty helps
// nobody. "none" and "" are both "no milestone".
func (c *Client) FindMilestone(title string) (*wire.Milestone, error) {
if title == "" || title == "none" {
return nil, nil
}
got, err := c.ListMilestones()
if err != nil {
return nil, err
}
for i := range got {
if got[i].Title == title {
return &got[i], nil
}
}
return nil, nil
}
+88
View File
@@ -0,0 +1,88 @@
package gitea
import (
"encoding/json"
"os"
"path/filepath"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// RemoteMapName is the ledger's file name, beside the issues it indexes.
const RemoteMapName = ".remote.json"
// RemoteMap is the number -> slug ledger: {"owner/repo#42": "wire-sqlc-appclick"}.
//
// ITS ENTRIES OUTLIVE THE FILES THEY NAME, and that is deliberate rather than a
// leak. A push deletes an issue's file the moment the tracker confirms the
// write, and the entry left behind is what makes the next pull of that number
// land on the same slug — so every `depends:` that pointed at it still
// resolves. Nothing prunes them, because "no file" no longer means "no such
// issue"; eviction does not prune it either, for the same reason a push does
// not. A stale entry costs one line of JSON and is corrected the next time that
// number is pulled.
//
// It is a cache, not a record. The slug also travels tracker-side, in the issue
// body, so losing this file costs a re-pull and not information — which is why
// Load never fails and why a rebuild is a MERGE and never a replacement. The
// order of authority:
//
// the tracker the issue, and the marker naming its slug
// .remote.json a local number -> slug ledger, a cache of that marker
// the store whatever happens to be checked out right now
//
// The store is a subset of what the ledger knows, so a rebuild that started
// from the files alone would throw away every entry it cannot see. Start from
// Load, add what the files say, Save.
type RemoteMap map[string]string
// RemoteMapPath is where the ledger lives: inside the issue store, beside the
// issues. root is the STORE, not the payload scratchpad — this file is
// bookkeeping about issues and belongs where they are.
func RemoteMapPath(root string) string { return filepath.Join(root, RemoteMapName) }
// LoadRemoteMap reads the ledger.
//
// A missing, unreadable or malformed file is an empty ledger and never an
// error. The ledger is a cache of markers the tracker holds, so refusing to run
// because it cannot be parsed would block the very pull that would rebuild it —
// and the cost of starting empty is one re-pull, never a lost issue.
func LoadRemoteMap(root string) RemoteMap {
raw, err := os.ReadFile(RemoteMapPath(root))
if err != nil {
return RemoteMap{}
}
var got RemoteMap
if err := json.Unmarshal(raw, &got); err != nil || got == nil {
return RemoteMap{}
}
return got
}
// Save writes the ledger, creating the directory if it is not there.
//
// The one write in this package allowed to create the store, and only because
// of when it happens: the ledger is written the instant the tracker confirms a
// push and BEFORE the local file is deleted, so failing it over a missing
// directory would lose the slug at exactly the moment the local copy stops
// being the record.
//
// Indented and key-sorted — encoding/json sorts map keys for us — because this
// file is read by people and diffed by git as often as it is read by the
// binary.
func (m RemoteMap) Save(root string) error {
if err := os.MkdirAll(root, 0o755); err != nil {
return err
}
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(RemoteMapPath(root), append(raw, '\n'), 0o644)
}
// Slug is the local name recorded for a key, or "".
func (m RemoteMap) Slug(k wire.Key) string { return m[k.String()] }
// Set records that a key is known locally under this slug.
func (m RemoteMap) Set(k wire.Key, slug string) { m[k.String()] = slug }
+80
View File
@@ -0,0 +1,80 @@
package gitea_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func TestRemoteMapRoundTrips(t *testing.T) {
root := filepath.Join(t.TempDir(), "issues")
key := wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 42}
m := gitea.RemoteMap{}
m.Set(key, "wire-sqlc-appclick")
if err := m.Save(root); err != nil {
t.Fatalf("Save: %v", err)
}
path := gitea.RemoteMapPath(root)
if want := filepath.Join(root, ".remote.json"); path != want {
t.Errorf("the ledger is at %s, want %s — beside the issues it indexes", path, want)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading the ledger: %v", err)
}
if !strings.Contains(string(raw), `"acme/widgets#42": "wire-sqlc-appclick"`) {
t.Errorf("the ledger is not readable by a human:\n%s", raw)
}
back := gitea.LoadRemoteMap(root)
if got := back.Slug(key); got != "wire-sqlc-appclick" {
t.Errorf("the key came back as %q, want wire-sqlc-appclick", got)
}
if got := back.Slug(wire.Key{Repo: key.Repo, Number: 7}); got != "" {
t.Errorf("an unrecorded key answered %q", got)
}
// A rebuild is a merge and never a replacement: what is already recorded
// survives an entry added on top of it. This is what makes a pull of a
// number whose file was deleted by a push land on the same slug.
second := wire.Key{Repo: key.Repo, Number: 43}
back.Set(second, "drop-the-wiki")
if err := back.Save(root); err != nil {
t.Fatalf("Save: %v", err)
}
again := gitea.LoadRemoteMap(root)
if again.Slug(key) != "wire-sqlc-appclick" || again.Slug(second) != "drop-the-wiki" {
t.Errorf("a second save lost an entry: %v", again)
}
}
// The ledger is a cache of markers the tracker holds, so an unreadable one must
// not stop the pull that would rebuild it.
func TestRemoteMapSurvivesAMissingOrMangledFile(t *testing.T) {
root := t.TempDir()
if got := gitea.LoadRemoteMap(filepath.Join(root, "nowhere")); len(got) != 0 {
t.Errorf("a missing ledger loaded as %v", got)
}
if err := os.WriteFile(gitea.RemoteMapPath(root), []byte("{ not json at all"), 0o644); err != nil {
t.Fatal(err)
}
got := gitea.LoadRemoteMap(root)
if len(got) != 0 {
t.Errorf("a mangled ledger loaded as %v", got)
}
// Still writable afterwards: an unreadable ledger costs a re-pull, not a run.
got.Set(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}, "first")
if err := got.Save(root); err != nil {
t.Fatalf("Save over a mangled ledger: %v", err)
}
if gitea.LoadRemoteMap(root).Slug(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}) != "first" {
t.Error("the ledger did not come back after being rewritten")
}
}