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:
naudachu
2026-08-11 19:29:38 +05:00
parent 9480e48312
commit 1239fdee70
292 changed files with 51205 additions and 864 deletions
+26 -19
View File
@@ -9,6 +9,8 @@ import (
"strings"
"time"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
@@ -172,7 +174,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
State: *state, Labels: labels, Query: query, Milestone: *milestone,
Limit: *limit,
Keep: func(p *wire.Issue) bool {
Keep: func(p *sdk.Issue) bool {
return pullLandsInStore(p, dropClosed, namer)
},
})
@@ -190,13 +192,18 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
seen := map[int]bool{}
for _, t := range queue {
seen[t.payload.Number] = true
seen[int(t.payload.Index)] = true
}
for len(queue) > 0 {
task := queue[0]
queue = queue[1:]
p := task.payload
// The SDK spells an issue number `Index`, and int64. It is
// an int everywhere on this side of the transport — in the
// ledger, in a key, in `depends:` — so it is narrowed once,
// here, rather than cast at every use.
number := int(p.Index)
id, err := namer.idFor(p)
if err != nil {
return err
@@ -208,13 +215,13 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
// links, and its blockers are not followed. The slug stays
// unclaimed too, so no other issue ends up pointing
// `depends:` at a file that is not there.
if dropClosed && p.State == "closed" && !stored {
dropped = append(dropped, p.Number)
if dropClosed && p.State == sdk.StateClosed && !stored {
dropped = append(dropped, number)
continue
}
namer.taken[id] = true
numberOf[p.Number] = id
numberOf[number] = id
// The native links, fetched ONCE for the two things they are
// for: filling this issue's `depends:` and telling the walk
@@ -222,7 +229,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
// store, and only one.
var blockers []int
if !*noDeps {
if blockers, err = pullBlockers(client, p.Number, repo); err != nil {
if blockers, err = pullBlockers(client, number, repo); err != nil {
return err
}
}
@@ -248,10 +255,10 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
if _, err := issue.Save(root, next); err != nil {
return err
}
if _, err := pullSyncComments(client, root, id, p.Number, p.Comments); err != nil {
if _, err := pullSyncComments(client, root, id, number, p.Comments); err != nil {
return err
}
ledger.Set(wire.Key{Repo: repo, Number: p.Number}, id)
ledger.Set(wire.Key{Repo: repo, Number: number}, id)
written = append(written, id)
pending = append(pending, unresolved{id, missing})
}
@@ -320,7 +327,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
// pullTask is one issue to walk, and how far from a seed it was found.
type pullTask struct {
payload *wire.Issue
payload *sdk.Issue
depth int
}
@@ -387,8 +394,8 @@ func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilt
len(listing.Issues), strings.Join(what, " + "), f.State)
out := make([]pullTask, 0, len(listing.Issues))
for i := range listing.Issues {
out = append(out, pullTask{payload: &listing.Issues[i]})
for _, p := range listing.Issues {
out = append(out, pullTask{payload: p})
}
return out, nil
}
@@ -401,8 +408,8 @@ func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilt
// only when the store already has it (it is refreshed, and that is a write);
// anything else counts, including one --cached will skip, because a skipped
// issue is still an issue the store holds when the run ends.
func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
if !dropClosed || p.State != "closed" {
func pullLandsInStore(p *sdk.Issue, dropClosed bool, namer *pullNamer) bool {
if !dropClosed || p.State != sdk.StateClosed {
return true
}
id, err := namer.idFor(p)
@@ -423,13 +430,13 @@ func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
// either resolve to the wrong issue or invent an edge. The body still names it,
// so nothing is lost.
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
deps, err := c.Dependencies(number)
keys, err := c.DependencyKeys(number)
if err != nil {
return nil, err
}
var out []int
for i := range deps {
if k := deps[i].KeyIn(repo); k.Repo == repo {
for _, k := range keys {
if k.Repo == repo {
out = append(out, k.Number)
}
}
@@ -444,7 +451,7 @@ func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
// absence of the file is the answer, not a gap in what was asked for.
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
path := commentsSidecarPath(root, id)
var thread []wire.Comment
var thread []*sdk.Comment
if count > 0 {
var err error
if thread, err = c.ListComments(number); err != nil {
@@ -530,8 +537,8 @@ type pullNamer struct {
// idFor is the slug this remote issue belongs under. Three sources, in order —
// see the command's own documentation for why that order and not another.
func (n *pullNamer) idFor(p *wire.Issue) (string, error) {
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: p.Number}); got != "" {
func (n *pullNamer) idFor(p *sdk.Issue) (string, error) {
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: int(p.Index)}); got != "" {
return got, nil
}
marked := mapping.IDInBody(p.Body)