f18a633185
The plugin required `tea`, Gitea's own CLI, for everything that is not an issue: releases, pull requests, milestones, branches, actions, webhooks. That put a second binary, a second set of logins nothing here could see, and 400 lines documenting somebody else's flags outside anything this repository can test. One command over the transport that already existed removes all three. Transport: `post` — the hand-rolled request the SDK cannot express, written for the dependency endpoint — is generalized to an exported `Do`, and `post` is three lines on top of it. Same http.Client, so the same RoundTripper files the body under .kettle/payload/, the same `token …` header authenticates it, and a non-2xx is the same *APIError. It does not paginate, does not reformat the answer, and names no domain concept, so the layering test is untouched. The endpoint rule is `tea api`'s, so an endpoint table written for that tool still works — with one restriction it did not have: a full URL must be on this instance. Every request carries the project's token in a header, and a URL on another host would hand the token to whatever was typed. Command: `kettle api <endpoint>` in a new `api` group, so the generator writes plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one word. No --repo and no --login, for the reason no sync command has them: a cross-repository address is an address, and another instance is KETTLE_URL. `-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision. Scopes: a token minted for issues carries write:issue and answers 403 on the first request outside issues, naming no scope. Gitea cannot be asked what a token may do — its own token listing needs a password — so `auth add --scopes` records it, `auth list` and `config` show it, and a 403 says which category it is likely to be. Documentation only; nothing is checked against it. skills/use — the tea reference, 239 lines of it — becomes skills/api: what to ask for, which endpoints paginate, and how to write a body. Every mention of `tea` as a requirement is gone from the manifests, the READMEs, the runner and the four other skills; what survives is the back-compat with the old plugin, which is a decision and not a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
606 lines
24 KiB
Go
606 lines
24 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 Do sends requests by hand — the
|
|
// dependency endpoint the SDK cannot spell, and every endpoint this package
|
|
// has no method for.
|
|
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
|
|
}
|
|
out := fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
|
if e.Status == http.StatusForbidden {
|
|
out += "\n" + scopeHint
|
|
}
|
|
return out
|
|
}
|
|
|
|
// scopeHint is what a 403 gets said after it.
|
|
//
|
|
// Gitea scopes a token as <read|write>:<category>, and a token minted to file
|
|
// issues carries `write:issue` and nothing more — which is exactly right until
|
|
// the first request outside issues, where releases, pull requests, branches and
|
|
// tags all live under `repository` and the answer is a 403 that names no scope
|
|
// at all. The server will not say which one is missing, so this does not guess
|
|
// one; it names the two commands that show what was recorded and let it be
|
|
// re-recorded.
|
|
//
|
|
// Blanket rather than per-call, because the transport does not know which
|
|
// category an arbitrary endpoint belongs to — and a 403 on a request that had
|
|
// the scope is a permissions problem on the repository, which this sentence does
|
|
// not contradict.
|
|
const scopeHint = "a 403 is usually the token's scopes rather than the request: Gitea scopes a token as " +
|
|
"<read|write>:<category>, and everything outside issues (releases, pull requests, branches, tags, actions) " +
|
|
"is `repository`. `kettle auth list` shows what each login on this machine records."
|
|
|
|
// 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 requests the SDK cannot express
|
|
// --------------------------------------------------------------------------
|
|
|
|
// Do sends one request to a path under this instance's API and returns the
|
|
// status and the body exactly as they came back.
|
|
//
|
|
// It is the escape hatch, and it was here before it was one: the dependency
|
|
// endpoint needed a body the SDK's own type cannot spell (see AddDependency),
|
|
// so a hand-rolled request already existed. What has changed is that it is
|
|
// exported, which is what lets `kettle api` reach a release, a pull request or a
|
|
// webhook without this package growing a method per entity — and without a
|
|
// second client that would hold the credentials all over again.
|
|
//
|
|
// It goes through the same http.Client as everything else, which is the whole
|
|
// point: the same dump-RoundTripper files the body under `.kettle/payload/`, the
|
|
// same `token …` header authenticates it, and a non-2xx comes back as the same
|
|
// *APIError carrying the status AND what the server said.
|
|
//
|
|
// THREE THINGS IT DELIBERATELY DOES NOT DO:
|
|
//
|
|
// - IT DOES NOT PAGINATE. One call is one HTTP request. The pagination in this
|
|
// package exists for a listing with a budget to spend, and a passthrough that
|
|
// quietly stitched pages together would report as one answer something that
|
|
// was several — `?page=` and `?limit=` are the caller's to spell.
|
|
// - IT DOES NOT PARSE OR REFORMAT THE ANSWER. Bytes in, bytes out. Whoever
|
|
// asked knows what they asked for; re-indenting it here would only be a
|
|
// second opinion about somebody else's JSON.
|
|
// - IT DOES NOT KNOW WHAT AN ISSUE IS. Nothing about it names a domain concept,
|
|
// so the layering rule holds unchanged — this is still transport, and a
|
|
// generic one is no more a domain than a specific one was.
|
|
//
|
|
// A nil body sends no body at all, which is what a GET and a DELETE want; the
|
|
// Content-Type goes on only when there is something to type.
|
|
func (c *Client) Do(method, path string, body []byte, name string) (int, []byte, error) {
|
|
endpoint, err := c.endpoint(path)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequest(method, endpoint, reader)
|
|
if err != nil {
|
|
return 0, nil, 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)
|
|
if body != nil {
|
|
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 0, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
answer, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return resp.StatusCode, nil, fmt.Errorf("%s %s: %d answered with a body that could not be read: %w",
|
|
method, endpoint, resp.StatusCode, err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return resp.StatusCode, answer, &APIError{
|
|
Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
|
}
|
|
return resp.StatusCode, answer, nil
|
|
}
|
|
|
|
// endpoint resolves what a caller spelled into an absolute URL on this instance.
|
|
//
|
|
// The three spellings are the ones `tea api` accepted, so a table of endpoints
|
|
// written for that tool still works here: a bare path is under `/api/v1/`, a
|
|
// path already starting `/api/` is taken as it stands (that is how anything
|
|
// outside v1 is reached), and a full URL is a full URL.
|
|
//
|
|
// A FULL URL MUST BE ON THIS INSTANCE, and that is the one place this is
|
|
// stricter than the tool it replaces. Every request made here carries the
|
|
// project's token in a header; a URL pointing somewhere else would hand that
|
|
// token to whatever host was named, which is a credential leak spelled as a
|
|
// convenience. Reaching another instance is what KETTLE_URL is for.
|
|
func (c *Client) endpoint(path string) (string, error) {
|
|
switch {
|
|
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
|
|
if path != c.base && !strings.HasPrefix(path, c.base+"/") {
|
|
return "", fmt.Errorf("%s is not on %s — this token belongs to that instance and is sent nowhere else"+
|
|
" (point %s at the other one instead)", path, c.base, config.EnvURL)
|
|
}
|
|
return path, nil
|
|
case strings.HasPrefix(path, "/api/"):
|
|
return c.base + path, nil
|
|
default:
|
|
return c.base + "/api/v1/" + strings.TrimLeft(path, "/"), nil
|
|
}
|
|
}
|
|
|
|
// post sends one JSON body to a path under this instance's API and ignores
|
|
// whatever comes back. AddDependency is what it is for.
|
|
func (c *Client) post(path string, body any, name string) error {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _, err = c.Do(http.MethodPost, path, raw, name)
|
|
return err
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 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}
|
|
}
|