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,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...))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user