// 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 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. // // 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. package gitea import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "unicode/utf8" "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" // 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 { // 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 base string // instance URL with the API prefix, 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. 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 ")) } 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(""), }, nil } // Repo is the repository every path is built 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. 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 // `.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 } // 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 } // 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 } 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 // -------------------------------------------------------------------------- // writePayload marshals a request body, files a copy under `.kettle/payload/`, // and returns the bytes to send. // // The file survives the call, for a retry or a post-mortem. // // 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 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("") } 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) } 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 } // name 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" } 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) if safe = strings.Trim(safe, "-"); safe == "" { return "request" } return safe } // -------------------------------------------------------------------------- // 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 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 // 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 = "&" } 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 { 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](c *Client, path string, limit int) ([]T, error) { var out []T err := pages(c, path, 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...)) }