01fb5a2703
There is no CI: the instance has no act_runner and none is planned, so releases are cut by hand. That makes `make check` the only thing standing between a mistake and the tracker, and it is one command: gofmt, vet, the suite with the cache defeated, `go mod verify`, a vendored build, and `kettle gen skills --check`. The last one is the invariant worth having — the plugin's SKILL.md command reference is generated from the binary's registry, so a flag that changed cannot ship with documentation that recommends the old one. `cli/cmd/release` publishes to Gitea using the same SDK the binary already vendors, which is a pleasing thing to be able to say: nothing third-party handles the artifacts. It is a second binary rather than a `kettle` subcommand on purpose — `kettle`'s command tree is what generates the plugin's skills, so a verb there ships to every operator, and publishing a release is build infrastructure. It is idempotent end to end: an existing release for the tag is reused, an asset of the same name is replaced rather than doubled, and a retried run converges instead of duplicating. `make release` refuses three things, each with its own message: a dirty working tree, a TAG that is not what `git describe` reports, and a tag the remote does not have. A release built from uncommitted code is unreproducible and nobody finds out until they need to reproduce it. `kettle version` reports the stamp, the toolchain and the VCS revision. The default is `dev`, and a hand build says so and means it — a binary out of somebody's working tree is not a release and must not claim to be one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
512 lines
19 KiB
Go
512 lines
19 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 payload shapes are code.gitea.io/sdk/gitea's, aliased `sdk` everywhere it
|
|
// is imported so that one type has one spelling across the tree. They are not
|
|
// this package's to own and never were: the bridge needs exactly the same
|
|
// vocabulary and cannot import a transport to get it, and a copy on each side
|
|
// is two structs that drift and a command that copies fields between them by
|
|
// hand. The issue KEYS are still internal/wire's — the SDK addresses an issue
|
|
// as (owner, repo, int64) and never parses `owner/repo#42` out of anything.
|
|
//
|
|
// WHAT THIS PACKAGE IS, NOW THAT THE SDK EXISTS: the one place that holds the
|
|
// credentials, the scratchpad and the repository this project points at, so
|
|
// that no command has to. Every method here is a thin wrapper, and the three
|
|
// things the wrapping is for are the three things the SDK does not do:
|
|
//
|
|
// - every request body is filed under `.kettle/payload/` by a RoundTripper,
|
|
// so a retry or a post-mortem has the bytes that went out;
|
|
// - every failure comes back as an *APIError carrying the status AND what the
|
|
// server said, because "500" on its own has never helped anybody;
|
|
// - a listing stops when the caller has what it asked for, which a client
|
|
// that fetches whole pages into a slice cannot do.
|
|
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"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 (
|
|
// 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 {
|
|
// api is the SDK client: one per run, shared by every copy For makes.
|
|
api *sdk.Client
|
|
// http is the SDK's transport, kept because AddDependency still sends one
|
|
// request by hand — see there.
|
|
http *http.Client
|
|
// dump is the RoundTripper that files request bodies. Shared with every
|
|
// copy For makes, because the scratchpad is one directory per run.
|
|
dump *dumper
|
|
|
|
base string // instance URL, no API prefix and no trailing slash
|
|
token string
|
|
repo wire.Repo
|
|
}
|
|
|
|
// 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. That check comes
|
|
// first because building the client now DIALS — the SDK asks the instance for
|
|
// its version before it hands one back — and a missing token reported as a
|
|
// connection failure sends the operator to the wrong place.
|
|
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 "))
|
|
}
|
|
|
|
base := strings.TrimRight(cfg.URL, "/")
|
|
dump := &dumper{next: http.DefaultTransport, root: project.PayloadRoot("")}
|
|
hc := &http.Client{Timeout: requestTimeout, Transport: dump}
|
|
|
|
api, err := sdk.NewClient(base,
|
|
sdk.SetToken(cfg.Token),
|
|
sdk.SetHTTPClient(hc),
|
|
sdk.SetUserAgent(userAgent))
|
|
if err != nil {
|
|
// A version string the SDK cannot parse is not a cosmetic failure, and
|
|
// it is refused here rather than shrugged off: the SDK hands back a
|
|
// usable-looking client that has quietly decided the server is Gitea
|
|
// 1.11, and its 1.11 compatibility path rewrites an issue's URL from a
|
|
// `repository` field a modern payload need not carry — a nil
|
|
// dereference on the first issue read. Saying so at the handshake beats
|
|
// crashing three calls later.
|
|
if errors.Is(err, &sdk.ErrUnknownVersion{}) {
|
|
return nil, fmt.Errorf("%s did not answer with a version this can read (%w)"+
|
|
" — check that %s points at a Gitea instance", base, err, config.EnvURL)
|
|
}
|
|
return nil, fmt.Errorf("cannot reach the Gitea instance at %s: %w", base, err)
|
|
}
|
|
return &Client{
|
|
api: api,
|
|
http: hc,
|
|
dump: dump,
|
|
base: base,
|
|
token: cfg.Token,
|
|
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
|
|
}, nil
|
|
}
|
|
|
|
// Repo is the repository every call is made 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.
|
|
//
|
|
// Bookkeeping and not a second connection: the SDK takes the owner and the name
|
|
// per call, so what changes is which pair this client passes. The credentials,
|
|
// the negotiated server version and the scratchpad are all shared, which is
|
|
// what makes `kettle pull owner/repo#42` cost nothing extra.
|
|
func (c *Client) For(r wire.Repo) *Client {
|
|
out := *c
|
|
out.repo = r
|
|
return &out
|
|
}
|
|
|
|
// owned is the owner and name every SDK call takes, escaped by the SDK itself.
|
|
func (c *Client) owned() (string, string) { return c.repo.Owner, c.repo.Name }
|
|
|
|
// --------------------------------------------------------------------------
|
|
// what a failure says
|
|
// --------------------------------------------------------------------------
|
|
|
|
// 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
|
|
}
|
|
|
|
// Fail is fail, exported for the one caller outside this package that needs it.
|
|
//
|
|
// `cmd/release` builds its own SDK client — see its package doc for why a build
|
|
// tool must not use this one — but a failure it reports has to name a status
|
|
// and quote what the server said in the same words a push does. One function,
|
|
// so the two spellings of "the tracker said no" cannot drift apart.
|
|
func Fail(resp *sdk.Response, err error) error { return fail(resp, err) }
|
|
|
|
// fail turns one SDK call's (response, error) pair into this package's error.
|
|
//
|
|
// BOTH HALVES OR NEITHER. The SDK reads the response body to build its error
|
|
// and then closes it, so the body is only ever available through err; the
|
|
// status and the request line are only ever available through resp. Neither is
|
|
// a diagnosis on its own, and dropping either is how "the tracker said no"
|
|
// becomes a message nobody can act on.
|
|
//
|
|
// A 2xx that still errored is a decode failure, not an answer the server
|
|
// refused: it keeps the status out of the message and the shape out of
|
|
// StatusIs, because a caller asking "was that a 409" must not be told yes by a
|
|
// body it could not parse.
|
|
func fail(resp *sdk.Response, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if resp == nil || resp.Response == nil {
|
|
return err // never reached the server; the URL is already in the error
|
|
}
|
|
method, endpoint := "", ""
|
|
if r := resp.Request; r != nil {
|
|
method, endpoint = r.Method, r.URL.String()
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: err.Error()}
|
|
}
|
|
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected: %w",
|
|
method, endpoint, resp.StatusCode, err)
|
|
}
|
|
|
|
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
|
|
// --------------------------------------------------------------------------
|
|
|
|
// dumper is the RoundTripper that files a copy of every request body under
|
|
// `.kettle/payload/`.
|
|
//
|
|
// The file survives the call, for a retry or a post-mortem.
|
|
//
|
|
// A RoundTripper and not a call site's decision, because a call site can forget
|
|
// and a RoundTripper cannot: it sees every request the SDK builds, including
|
|
// the ones no method of this package spelled out. What a call site still
|
|
// supplies is the NAME — see label — because an issue's slug identifies the
|
|
// call in a post-mortem and a serial number does not.
|
|
//
|
|
// 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 run
|
|
// with nothing to send — every read-only command, and the version handshake
|
|
// every run opens with — leaves no directory behind.
|
|
type dumper struct {
|
|
next http.RoundTripper
|
|
// root is resolved once, by New, and is never taken from a caller.
|
|
root string
|
|
|
|
mu sync.Mutex
|
|
name string
|
|
}
|
|
|
|
// label names the file the next request body lands in. One label serves one
|
|
// request: it is taken, not read, so a request the SDK makes on its own account
|
|
// cannot end up filed under the last thing a command was doing.
|
|
func (d *dumper) label(name string) {
|
|
d.mu.Lock()
|
|
d.name = name
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
func (d *dumper) take() string {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
name := d.name
|
|
d.name = ""
|
|
return name
|
|
}
|
|
|
|
// RoundTrip files the body and then sends the request.
|
|
//
|
|
// A dump that cannot be written fails the call before it is made, which is the
|
|
// order the old hand-rolled client had and worth keeping: the point of the file
|
|
// is to hold what was sent, and one that does not exist for a request that did
|
|
// is worse than not having sent it.
|
|
func (d *dumper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if err := d.file(req); err != nil {
|
|
return nil, err
|
|
}
|
|
return d.next.RoundTrip(req)
|
|
}
|
|
|
|
func (d *dumper) file(req *http.Request) error {
|
|
name := d.take()
|
|
if req.Body == nil || req.GetBody == nil {
|
|
return nil // a read: nothing to file
|
|
}
|
|
body, err := req.GetBody()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer body.Close()
|
|
raw, err := io.ReadAll(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(bytes.TrimSpace(raw)) == 0 {
|
|
return nil
|
|
}
|
|
if d.root == "" {
|
|
return project.NotFoundError("")
|
|
}
|
|
if name == "" {
|
|
name = derivedName(req)
|
|
}
|
|
|
|
if err := os.MkdirAll(d.root, 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(d.root, safeName(name)+".json"), readable(raw), 0o644)
|
|
}
|
|
|
|
// readable is the request body as a person reads it: indented, and with the
|
|
// markup left alone.
|
|
//
|
|
// The SDK marshals with encoding/json's defaults, which escape `<`, `>` and `&`
|
|
// into their \u00xx spellings. An issue body carries `<!-- … -->` markers and
|
|
// prose full of `&`, and a dump escaped that way is unreadable exactly when
|
|
// somebody is reading it because something went wrong.
|
|
//
|
|
// So the bytes are re-encoded rather than filed verbatim: same JSON VALUE, and
|
|
// numbers verbatim (UseNumber, so an id is not rounded through a float), but
|
|
// not the same bytes. What that costs is byte-for-byte fidelity with the wire —
|
|
// what it buys is a file anybody can read and re-POST. Anything that will not
|
|
// parse is filed as it came, because a dump of something surprising is exactly
|
|
// the dump worth having.
|
|
func readable(raw []byte) []byte {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var v any
|
|
if err := dec.Decode(&v); err != nil {
|
|
return raw
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetIndent("", " ")
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
return raw
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// derivedName is what an unnamed request is filed under: its method and its
|
|
// path. Nobody has to remember to name a call for its body to be kept — a name
|
|
// only makes the file easier to find.
|
|
func derivedName(req *http.Request) string {
|
|
return strings.ToLower(req.Method) + "-" + strings.TrimPrefix(req.URL.Path, "/api/v1/")
|
|
}
|
|
|
|
// safeName 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 safeName(name string) string {
|
|
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 '-'
|
|
}, name)
|
|
if safe = strings.Trim(safe, "-"); safe == "" {
|
|
return "request"
|
|
}
|
|
return safe
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// the one request the SDK cannot express
|
|
// --------------------------------------------------------------------------
|
|
|
|
// post sends one JSON body to a path under this instance's API and ignores
|
|
// whatever comes back.
|
|
//
|
|
// It exists for AddDependency and for nothing else — see there for what the SDK
|
|
// leaves out. It goes through the same http.Client, so the body is filed and a
|
|
// failure carries the status and the server's words exactly as every other call
|
|
// in this package does.
|
|
func (c *Client) post(path string, body any, name string) error {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
endpoint := c.base + "/api/v1/" + strings.TrimLeft(path, "/")
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
|
|
if err != nil {
|
|
return 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("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", userAgent)
|
|
|
|
c.dump.label(name)
|
|
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 err
|
|
}
|
|
defer resp.Body.Close()
|
|
answer, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return &APIError{Method: http.MethodPost, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 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 calls fetch 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.
|
|
// That is the one thing the SDK's own list options cannot do — they describe a
|
|
// page, and this describes when to stop asking for another.
|
|
func pages[T any](fetch func(page, limit int) ([]T, error), limit, budget int, each func([]T) (bool, error)) error {
|
|
for page := 1; page <= budget; page++ {
|
|
batch, err := fetch(page, limit)
|
|
if 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](fetch func(page, limit int) ([]T, error), limit int) ([]T, error) {
|
|
var out []T
|
|
err := pages(fetch, limit, maxPages, func(batch []T) (bool, error) {
|
|
out = append(out, batch...)
|
|
return true, nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// listOptions is one page, as the SDK asks for it.
|
|
func listOptions(page, limit int) sdk.ListOptions {
|
|
return sdk.ListOptions{Page: page, PageSize: limit}
|
|
}
|