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
+73 -37
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"
@@ -241,7 +243,7 @@ and a warning says so.`,
return err
}
if m != nil {
msID = wire.Set(m.ID)
msID = sdk.OptionalInt64(m.ID)
}
milestones[i.Milestone] = msID
}
@@ -252,17 +254,17 @@ and a warning says so.`,
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
sent, synced := mapping.NumberOf(i)
var got *wire.Issue
var got *sdk.Issue
verb := "created"
if synced {
// An edit says what state it means; a create takes the
// tracker's default.
opt.IncludeState = true
verb = "updated"
got, err = client.EditIssue(sent, *mapping.ToRequest(i, opt), "issue-"+id)
got, err = client.EditIssue(sent, mapping.ToEdit(i, opt), "issue-"+id)
} else {
sent = 0
got, err = client.CreateIssue(*mapping.ToRequest(i, opt), "issue-"+id)
got, err = client.CreateIssue(mapping.ToCreate(i, opt), "issue-"+id)
}
// THE GATE. Below this line a local file is going to be
// deleted, so anything short of a confirmed write stops the
@@ -274,7 +276,7 @@ and a warning says so.`,
if !confirmed {
return Fail("%s: the tracker's answer does not confirm the write "+
"(it carries number %d) — %s is untouched, nothing was deleted",
id, got.Number, issue.PathOf(root, id))
id, got.Index, issue.PathOf(root, id))
}
// The number is confirmed, so the ledger learns it NOW —
@@ -290,35 +292,30 @@ and a warning says so.`,
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
}
// Gitea occasionally drops labels handed to it on create, so
// the echo is checked and the set re-applied rather than
// trusted. A failure here is a warning and not an abort: the
// issue IS in the tracker, and a run that stopped now would
// leave a file whose `gitea:` field was never written — which
// the next push would file all over again as a new issue.
applied := map[string]bool{}
for _, l := range got.Labels {
applied[l.Name] = true
}
var ids []int64
var missing []string
for _, name := range i.Labels {
lid, in := labelIDs[name]
if !in {
continue
}
ids = append(ids, lid)
if !applied[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
if _, err := client.SetLabels(number, ids, "labels-"+id); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not re-apply labels (%s): %v\n",
id, strings.Join(missing, ", "), err)
// THE LABELS THE TRACKER ENDED UP WITH HAVE TO BE THE
// ISSUE'S, and two different things leave them disagreeing:
//
// - a create can DROP labels handed to it, which Gitea has
// been seen to do, so the echo is checked rather than
// trusted;
// - an edit cannot carry labels AT ALL — Gitea's PATCH
// takes none — so a label added or removed locally is
// not in the answer either way.
//
// One answer to both: compare the set the tracker echoed
// with the set this issue wants, and PUT the whole list when
// they differ. A failure here is a warning and not an abort:
// the issue IS in the tracker, and a run that stopped now
// would leave a file whose `gitea:` field was never written
// — which the next push would file all over again as a new
// issue.
if drift := pushLabelDrift(got.Labels, i.Labels, labelIDs); len(drift) > 0 {
if _, err := client.SetLabels(number, mapping.LabelIDsFor(i, opt), "labels-"+id); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not set the labels (%s): %v\n",
id, strings.Join(drift, ", "), err)
} else {
fmt.Fprintf(os.Stderr, "warning: %s: labels re-applied via PUT (%s)\n",
id, strings.Join(missing, ", "))
fmt.Fprintf(os.Stderr, "%s: labels set via PUT (%s)\n",
id, strings.Join(drift, ", "))
}
}
@@ -454,6 +451,44 @@ func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
return out, nil
}
// pushLabelDrift is the names on which the tracker's answer and the issue
// disagree — what a receipt says when the PUT that follows is made.
//
// BOTH DIRECTIONS. A wanted label the answer does not carry is one Gitea
// dropped or one an edit could not send; a label the answer carries that the
// issue no longer wants is one somebody removed locally, and leaving it would
// make `push --update` a command that can add a label but never take one off.
//
// Only labels this run resolved to an id count as wanted: an unknown name was
// already left off deliberately, and one the repository holds but the taxonomy
// does not know is not this command's to remove either — which is why the
// comparison is against the resolved set and not against `labels:` as written.
func pushLabelDrift(got []*sdk.Label, want []string, ids map[string]int64) []string {
have := map[string]bool{}
for _, l := range got {
if l != nil {
have[l.Name] = true
}
}
wanted := map[string]bool{}
var drift []string
for _, name := range want {
if _, resolved := ids[name]; !resolved {
continue
}
wanted[name] = true
if !have[name] {
drift = append(drift, name)
}
}
for _, l := range got {
if l != nil && !wanted[l.Name] {
drift = append(drift, "-"+l.Name)
}
}
return drift
}
// pushDep is what one `depends:` entry is, as far as linking is concerned.
type pushDep struct {
// Slug is the dependency as `depends:` spells it.
@@ -615,14 +650,15 @@ func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
// expected, or a `number` that is not a number — the transport fails all three
// before returning, and the file survives by never reaching the delete.
func pushConfirmedNumber(got *wire.Issue, sent int) (int, bool) {
if got == nil || got.Number <= 0 {
func pushConfirmedNumber(got *sdk.Issue, sent int) (int, bool) {
if got == nil || got.Index <= 0 {
return 0, false
}
if sent != 0 && got.Number != sent {
number := int(got.Index)
if sent != 0 && number != sent {
return 0, false
}
return got.Number, true
return number, true
}
// pushGitBranch is the branch HEAD is on, or "".