9480e48312
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>
382 lines
14 KiB
Go
382 lines
14 KiB
Go
// 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...))
|
|
}
|