Files
marketplace/cli/internal/gitea/client.go
T
naudachu 01fb5a2703 feat: publish releases with this repository's own SDK code
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>
2026-08-12 01:01:27 +05:00

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}
}