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:
+147
-80
@@ -3,10 +3,11 @@ package gitea
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
@@ -14,86 +15,98 @@ import (
|
||||
//
|
||||
// A number is an address, not a query: this answers for a closed issue exactly
|
||||
// as it does for an open one.
|
||||
func (c *Client) GetIssue(number int) (*wire.Issue, error) {
|
||||
var got wire.Issue
|
||||
if err := c.Call(http.MethodGet, c.repoPathf("issues/%d", number), nil, &got); err != nil {
|
||||
func (c *Client) GetIssue(number int) (*sdk.Issue, error) {
|
||||
owner, repo := c.owned()
|
||||
got, resp, err := c.api.GetIssue(owner, repo, int64(number))
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A 200 that carries no number is not this issue. Gitea has answered that
|
||||
// way for a repository whose issue tracker is disabled.
|
||||
if got.Number == 0 {
|
||||
if got == nil || got.Index == 0 {
|
||||
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
|
||||
}
|
||||
return &got, nil
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// CreateIssue files a new issue. name labels the request body in the
|
||||
// scratchpad; the issue's slug is what makes that dump worth keeping.
|
||||
func (c *Client) CreateIssue(req wire.IssueRequest, name string) (*wire.Issue, error) {
|
||||
var got wire.Issue
|
||||
body := &Body{Name: name, Data: req}
|
||||
if err := c.Call(http.MethodPost, c.repoPath("issues"), body, &got); err != nil {
|
||||
func (c *Client) CreateIssue(opt sdk.CreateIssueOption, name string) (*sdk.Issue, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.CreateIssue(owner, repo, opt)
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// EditIssue patches an existing issue. Only the fields set on req are sent.
|
||||
func (c *Client) EditIssue(number int, req wire.IssueRequest, name string) (*wire.Issue, error) {
|
||||
var got wire.Issue
|
||||
body := &Body{Name: name, Data: req}
|
||||
if err := c.Call(http.MethodPatch, c.repoPathf("issues/%d", number), body, &got); err != nil {
|
||||
// EditIssue patches an existing issue. Only the fields set on opt are sent.
|
||||
//
|
||||
// LABELS DO NOT GO THROUGH HERE. Gitea's edit endpoint takes no label list and
|
||||
// neither does the SDK's EditIssueOption, so an issue whose labels changed
|
||||
// needs SetLabels after this — push does exactly that, and says so.
|
||||
func (c *Client) EditIssue(number int, opt sdk.EditIssueOption, name string) (*sdk.Issue, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.EditIssue(owner, repo, int64(number), opt)
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// SetLabels replaces an issue's labels with exactly these ids.
|
||||
//
|
||||
// It exists because Gitea occasionally drops labels handed to it on create, and
|
||||
// the answer to that is to re-apply them rather than to trust the echo.
|
||||
func (c *Client) SetLabels(number int, ids []int64, name string) ([]wire.Label, error) {
|
||||
// the answer to that is to re-apply them rather than to trust the echo. It is
|
||||
// also the only way to change the labels of an issue that already exists — see
|
||||
// EditIssue.
|
||||
func (c *Client) SetLabels(number int, ids []int64, name string) ([]*sdk.Label, error) {
|
||||
if ids == nil {
|
||||
ids = []int64{}
|
||||
}
|
||||
var got []wire.Label
|
||||
body := &Body{Name: name, Data: struct {
|
||||
Labels []int64 `json:"labels"`
|
||||
}{ids}}
|
||||
if err := c.Call(http.MethodPut, c.repoPathf("issues/%d/labels", number), body, &got); err != nil {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.ReplaceIssueLabels(owner, repo, int64(number), sdk.IssueLabelsOption{Labels: ids})
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// ListComments is an issue's whole thread, every page of it.
|
||||
func (c *Client) ListComments(number int) ([]wire.Comment, error) {
|
||||
return paginate[wire.Comment](c, c.repoPathf("issues/%d/comments", number), pageLimit)
|
||||
func (c *Client) ListComments(number int) ([]*sdk.Comment, error) {
|
||||
owner, repo := c.owned()
|
||||
return paginate(func(page, limit int) ([]*sdk.Comment, error) {
|
||||
got, resp, err := c.api.ListIssueComments(owner, repo, int64(number),
|
||||
sdk.ListIssueCommentOptions{ListOptions: listOptions(page, limit)})
|
||||
return got, fail(resp, err)
|
||||
}, pageLimit)
|
||||
}
|
||||
|
||||
// CreateComment posts a comment on an issue.
|
||||
func (c *Client) CreateComment(number int, text, name string) (*wire.Comment, error) {
|
||||
var got wire.Comment
|
||||
body := &Body{Name: name, Data: commentBody{Body: text}}
|
||||
if err := c.Call(http.MethodPost, c.repoPathf("issues/%d/comments", number), body, &got); err != nil {
|
||||
func (c *Client) CreateComment(number int, text, name string) (*sdk.Comment, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.CreateIssueComment(owner, repo, int64(number),
|
||||
sdk.CreateIssueCommentOption{Body: text})
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// EditComment rewrites one comment, addressed by its own id and not by the
|
||||
// issue it is on — which is how Gitea addresses it.
|
||||
func (c *Client) EditComment(id int64, text, name string) (*wire.Comment, error) {
|
||||
var got wire.Comment
|
||||
body := &Body{Name: name, Data: commentBody{Body: text}}
|
||||
if err := c.Call(http.MethodPatch, c.repoPathf("issues/comments/%d", id), body, &got); err != nil {
|
||||
func (c *Client) EditComment(id int64, text, name string) (*sdk.Comment, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.EditIssueComment(owner, repo, id, sdk.EditIssueCommentOption{Body: text})
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &got, nil
|
||||
}
|
||||
|
||||
type commentBody struct {
|
||||
Body string `json:"body"`
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -121,13 +134,13 @@ type IssueFilter struct {
|
||||
// something to say about them ("11 closed, not stored") still can.
|
||||
//
|
||||
// What Keep means is the caller's business; this package only counts.
|
||||
Keep func(*wire.Issue) bool
|
||||
Keep func(*sdk.Issue) bool
|
||||
}
|
||||
|
||||
// IssueListing is what a filtered read found.
|
||||
type IssueListing struct {
|
||||
// Issues are every payload that passed the filter, kept or not.
|
||||
Issues []wire.Issue
|
||||
Issues []*sdk.Issue
|
||||
// Milestone is the resolved milestone title, for a receipt.
|
||||
Milestone string
|
||||
// Warning is set when a Keep-bounded read ran out of page budget with the
|
||||
@@ -167,23 +180,27 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
||||
milestoneID, out.Milestone = ms.ID, ms.Title
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
state := f.State
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
params.Set("state", state)
|
||||
params.Set("type", "issues")
|
||||
if len(f.Labels) > 0 {
|
||||
params.Set("labels", strings.Join(f.Labels, ","))
|
||||
owner, repo := c.owned()
|
||||
fetch := func(page, limit int) ([]*sdk.Issue, error) {
|
||||
opt := sdk.ListIssueOption{
|
||||
ListOptions: listOptions(page, limit),
|
||||
State: sdk.StateType(state),
|
||||
// Issues and not pull requests. The server has been known to
|
||||
// ignore this, which is why matches re-checks it.
|
||||
Type: sdk.IssueTypeIssue,
|
||||
Labels: f.Labels,
|
||||
KeyWord: f.Query,
|
||||
}
|
||||
if out.Milestone != "" {
|
||||
opt.Milestones = []string{out.Milestone}
|
||||
}
|
||||
got, resp, err := c.api.ListRepoIssues(owner, repo, opt)
|
||||
return got, fail(resp, err)
|
||||
}
|
||||
if f.Query != "" {
|
||||
params.Set("q", f.Query)
|
||||
}
|
||||
if out.Milestone != "" {
|
||||
params.Set("milestones", out.Milestone)
|
||||
}
|
||||
path := c.repoPath("issues?" + params.Encode())
|
||||
|
||||
perPage := min(f.Limit, pageLimit)
|
||||
ideal := max(1, (f.Limit+perPage-1)/perPage)
|
||||
@@ -193,15 +210,14 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
||||
}
|
||||
|
||||
kept, seen, lastFull := 0, 0, false
|
||||
err := pages(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) {
|
||||
err := pages(fetch, perPage, budget, func(batch []*sdk.Issue) (bool, error) {
|
||||
seen++
|
||||
lastFull = len(batch) == perPage
|
||||
for i := range batch {
|
||||
p := &batch[i]
|
||||
for _, p := range batch {
|
||||
if !matches(p, milestoneID, f.Labels) {
|
||||
continue
|
||||
}
|
||||
out.Issues = append(out.Issues, *p)
|
||||
out.Issues = append(out.Issues, p)
|
||||
if f.Keep == nil || f.Keep(p) {
|
||||
kept++
|
||||
if kept >= f.Limit {
|
||||
@@ -230,10 +246,10 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
||||
// matters most — a pull request rendered as a unit of work is not a bug the
|
||||
// operator can see until it is in the store.
|
||||
//
|
||||
// A function and not a method: the payload is the protocol's, and re-checking a
|
||||
// filter the server ignored is this package's business, not the protocol's.
|
||||
func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
|
||||
if i.IsPullRequest() {
|
||||
// A function and not a method: the payload is the SDK's, and re-checking a
|
||||
// filter the server ignored is this package's business, not the payload's.
|
||||
func matches(i *sdk.Issue, milestoneID int64, labels []string) bool {
|
||||
if i.PullRequest != nil {
|
||||
return false
|
||||
}
|
||||
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
|
||||
@@ -255,28 +271,46 @@ func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
|
||||
// dependencies
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// issueMeta is Gitea's IssueMeta: how a dependency names another issue.
|
||||
type issueMeta struct {
|
||||
Index int `json:"index"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
// dependenciesSince is the first Gitea release that answers at
|
||||
// /issues/{index}/dependencies at all.
|
||||
//
|
||||
// Checked against the release tags themselves and not guessed: the routes are
|
||||
// absent from routers/api/v1/api.go through 1.19 and present in 1.20. Asking
|
||||
// the version rather than the endpoint is what turns "some error came back"
|
||||
// into an answer — and it costs no request, because the SDK already negotiated
|
||||
// the version when the client was built.
|
||||
const dependenciesSince = ">= 1.20.0"
|
||||
|
||||
// hasDependencies reports whether this instance is new enough to have the
|
||||
// dependency endpoints.
|
||||
func (c *Client) hasDependencies() bool {
|
||||
return c.api.CheckServerVersionConstraint(dependenciesSince) == nil
|
||||
}
|
||||
|
||||
// Dependencies are the issues that block this one — Gitea's own dependency
|
||||
// links, read in the direction AddDependency writes them.
|
||||
//
|
||||
// An instance that does not have the endpoint, or has dependencies turned off
|
||||
// for this repository, answers with a status rather than a list. That is
|
||||
// reported as "no dependencies" and not as a failure: a pull must still bring
|
||||
// the issue itself back from a tracker whose dependency support is off.
|
||||
// TWO WAYS FOR THERE TO BE NO ANSWER, and both are reported as "no
|
||||
// dependencies" rather than as a failure, because a pull must still bring the
|
||||
// issue itself back:
|
||||
//
|
||||
// - the instance predates the endpoint, which the version says before a
|
||||
// request is made;
|
||||
// - the instance has it but this repository does not — dependencies turned
|
||||
// off, a tracker disabled — which only the tracker's own answer can say.
|
||||
//
|
||||
// Deliberately narrower than the Python it replaces, which swallowed every
|
||||
// failure here including a dead connection. "The server said no" and "there was
|
||||
// no server" are different answers, and only the first one means the feature is
|
||||
// missing.
|
||||
func (c *Client) Dependencies(number int) ([]wire.Issue, error) {
|
||||
var got []wire.Issue
|
||||
err := c.Call(http.MethodGet, c.repoPathf("issues/%d/dependencies", number), nil, &got)
|
||||
func (c *Client) Dependencies(number int) ([]*sdk.Issue, error) {
|
||||
if !c.hasDependencies() {
|
||||
return nil, nil
|
||||
}
|
||||
owner, repo := c.owned()
|
||||
got, resp, err := c.api.ListIssueDependencies(owner, repo, int64(number),
|
||||
sdk.ListIssueDependenciesOptions{ListOptions: listOptions(1, pageLimit)})
|
||||
err = fail(resp, err)
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return nil, nil
|
||||
@@ -298,12 +332,38 @@ func (c *Client) DependencyKeys(number int) ([]wire.Key, error) {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]wire.Key, 0, len(deps))
|
||||
for i := range deps {
|
||||
out = append(out, deps[i].KeyIn(c.repo))
|
||||
for _, d := range deps {
|
||||
out = append(out, keyIn(d, c.repo))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// keyIn is a payload's cross-repo handle. The payload's own repository wins
|
||||
// when it carries one — a dependency listing answers with issues from other
|
||||
// repositories — and fallback is the repository that was asked.
|
||||
func keyIn(p *sdk.Issue, fallback wire.Repo) wire.Key {
|
||||
repo := fallback
|
||||
if p.Repository != nil {
|
||||
if r, err := wire.ParseRepo(p.Repository.FullName); err == nil {
|
||||
repo = r
|
||||
}
|
||||
}
|
||||
return wire.Key{Repo: repo, Number: int(p.Index)}
|
||||
}
|
||||
|
||||
// issueMeta is Gitea's own IssueMeta: how a dependency names another issue.
|
||||
//
|
||||
// The SDK has a type of this name too and it carries only `index`, so it can
|
||||
// only ever link inside one repository. Gitea's has taken an owner and a repo
|
||||
// since the endpoint existed, and a `depends:` entry is allowed to live
|
||||
// somewhere else — so this one struct and the raw POST that sends it are all
|
||||
// that is left of the hand-rolled client.
|
||||
type issueMeta struct {
|
||||
Index int `json:"index"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
// AddDependency makes issue number depend on dep.
|
||||
//
|
||||
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
|
||||
@@ -324,9 +384,16 @@ func (c *Client) AddDependency(number int, dep wire.Key) error {
|
||||
if dep.Number < 1 {
|
||||
return fmt.Errorf("dependency %s names no issue number", dep)
|
||||
}
|
||||
body := &Body{
|
||||
Name: fmt.Sprintf("dep-%d-%d", number, dep.Number),
|
||||
Data: issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
|
||||
if !c.hasDependencies() {
|
||||
return fmt.Errorf("this Gitea has no issue-dependency API (it is not %s) — link #%d -> %s by hand",
|
||||
strings.TrimPrefix(dependenciesSince, ">= "), number, dep)
|
||||
}
|
||||
return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil)
|
||||
// Owner and name are escaped, the way the SDK escapes them for every other
|
||||
// call: they arrive from a config file, and a file is a thing people type
|
||||
// into.
|
||||
return c.post(
|
||||
fmt.Sprintf("repos/%s/%s/issues/%d/dependencies",
|
||||
url.PathEscape(c.repo.Owner), url.PathEscape(c.repo.Name), number),
|
||||
issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
|
||||
fmt.Sprintf("dep-%d-%d", number, dep.Number))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user