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