refactor: move the transport onto the official Gitea SDK
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+273
-151
@@ -10,17 +10,25 @@
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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 (
|
||||
@@ -30,21 +38,21 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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 (
|
||||
// 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"
|
||||
@@ -59,25 +67,28 @@ const (
|
||||
|
||||
// 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
|
||||
// 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 with the API prefix, no trailing slash
|
||||
base string // instance URL, no API prefix and 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.
|
||||
// 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")
|
||||
@@ -95,98 +106,61 @@ func New(cfg *config.Resolved) (*Client, error) {
|
||||
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{
|
||||
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(""),
|
||||
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 path is built against.
|
||||
// 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. The credentials and the scratchpad
|
||||
// come along; only the paths change.
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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 }
|
||||
|
||||
// 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
|
||||
}
|
||||
// --------------------------------------------------------------------------
|
||||
// what a failure says
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// APIError is a non-2xx answer, carrying both halves of what happened.
|
||||
//
|
||||
@@ -223,6 +197,36 @@ func StatusIs(err error, status int) bool {
|
||||
return errors.As(err, &apiErr) && apiErr.Status == status
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -240,11 +244,17 @@ func truncate(s string) string {
|
||||
// where request bodies land
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// writePayload marshals a request body, files a copy under `.kettle/payload/`,
|
||||
// and returns the bytes to send.
|
||||
// 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
|
||||
@@ -255,58 +265,179 @@ func truncate(s string) string {
|
||||
// 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("")
|
||||
// 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("", " ")
|
||||
// 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)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return raw
|
||||
}
|
||||
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
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// name is the file stem, with everything that is not plainly a file name folded
|
||||
// away.
|
||||
// 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 (b *Body) name() string {
|
||||
if b.Name == "" {
|
||||
return "request"
|
||||
}
|
||||
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 '-'
|
||||
}, b.Name)
|
||||
}, 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
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -327,21 +458,19 @@ const (
|
||||
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
|
||||
// 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.
|
||||
func pages[T any](c *Client, path string, limit, budget int, each func([]T) (bool, error)) error {
|
||||
sep := "?"
|
||||
if strings.Contains(path, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
// 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++ {
|
||||
var batch []T
|
||||
if err := c.Call(http.MethodGet, fmt.Sprintf("%s%spage=%d&limit=%d", path, sep, page, limit), nil, &batch); err != nil {
|
||||
batch, err := fetch(page, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
@@ -359,23 +488,16 @@ func pages[T any](c *Client, path string, limit, budget int, each func([]T) (boo
|
||||
}
|
||||
|
||||
// paginate follows a list endpoint to exhaustion and returns the whole list.
|
||||
func paginate[T any](c *Client, path string, limit int) ([]T, error) {
|
||||
func paginate[T any](fetch func(page, limit int) ([]T, error), limit int) ([]T, error) {
|
||||
var out []T
|
||||
err := pages(c, path, limit, maxPages, func(batch []T) (bool, error) {
|
||||
err := pages(fetch, 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...))
|
||||
// listOptions is one page, as the SDK asks for it.
|
||||
func listOptions(page, limit int) sdk.ListOptions {
|
||||
return sdk.ListOptions{Page: page, PageSize: limit}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user