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
+22
View File
@@ -1,3 +1,25 @@
// Package wire is the protocol's identifiers: the way this project addresses
// one repository and one issue, and nothing else.
//
// The JSON shapes used to live here too, because the transport and the bridge
// both had to name a Gitea issue and neither may import the other. They are
// code.gitea.io/sdk/gitea's now — one vocabulary, maintained by the people who
// maintain the server — and the reason the shapes were lifted out of the
// transport in the first place still holds: two structs for one payload drift,
// and the first field only one of them learns is a field the other silently
// drops.
//
// WHAT THE SDK HAS NO ANSWER FOR IS ADDRESSING. `42`, `#42`, `owner/repo#42`
// and an issue URL are four spellings of one thing, all four are what somebody
// has in hand, and the SDK takes an owner, a name and an int64 — it never
// parses. So the parsing stays, and so does the pair of types it produces: Repo
// and Key, the values that go into the ledger, into the `gitea:` metadata field
// and into every receipt.
//
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
// filesystem, no configuration, no SDK, and above all not internal/issue. An
// identifier that reached for any of those would drag every user of it into
// that layer. layering_test.go fails the moment it stops being true.
package wire
import (
+9 -9
View File
@@ -6,12 +6,12 @@ import (
"testing"
)
// The protocol is shared by two layers that may not import each other, and it
// can only be shared because it reaches for nothing itself: no domain, no
// configuration, no path resolution, no third party. One import from any of
// those would drag every user of this package into that layer — which is the
// whole reason these shapes were lifted out of the transport rather than left
// there for the bridge to reimplement.
// The identifiers are shared by two layers that may not import each other, and
// they can only be shared because they reach for nothing themselves: no domain,
// no configuration, no path resolution, no third party — the Gitea SDK
// included. One import from any of those would drag every user of this package
// into that layer, which is the whole reason an issue key is parsed here rather
// than wherever it is first needed.
//
// The dependency walk, so a helper pulled in three packages deep is caught as
// the same violation as one written at the top of a file.
@@ -28,7 +28,7 @@ func TestWireDependsOnNothing(t *testing.T) {
// because it has no domain name in front of it.
first, _, _ := strings.Cut(dep, "/")
if strings.Contains(first, ".") {
t.Errorf("the protocol imports %s — these are shapes and identifiers, and nothing else belongs here", dep)
t.Errorf("the protocol imports %s — these are identifiers, and nothing else belongs here", dep)
}
}
}
@@ -43,10 +43,10 @@ func TestWireReachesNeitherTheNetworkNorTheDisk(t *testing.T) {
forbidden := map[string]string{
"net/http": "an HTTP call belongs in the transport",
"net": "an HTTP call belongs in the transport",
"os": "a shape reads no file and no environment",
"os": "an identifier reads no file and no environment",
"os/exec": "nothing here shells out",
"io": "nothing here is a stream",
"time": "a timestamp crosses as the string the tracker sent",
"time": "an address has no timestamp in it",
}
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
-59
View File
@@ -1,59 +0,0 @@
package wire
// The bodies that go up, and the shorthand that fills them.
//
// The omitted keys carry meaning of their own on a PATCH: a key that is absent
// leaves the tracker's value alone, and a key that is present overwrites it. So
// "no opinion" and "empty" must not marshal the same way, which is what every
// pointer and every omitempty below is for.
// IssueRequest is the body of a create or an edit.
//
// Every field is a pointer because Gitea reads an absent key as "no opinion"
// and a present one as "make it this", and the difference is not academic: an
// empty `ref` CLEARS the branch an issue is pinned to, and an empty `labels`
// clears its labels. A caller meaning to change only the state would do both by
// accident with plain zero values. Set fills a field; leaving it nil leaves the
// tracker's copy alone.
type IssueRequest struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
// Labels is a pointer because `[]` is a statement — it clears every label
// on the issue — while a caller that has not resolved label ids at all has
// no business making it. A plain slice with omitempty cannot say both.
Labels *[]int64 `json:"labels,omitempty"`
Assignees *[]string `json:"assignees,omitempty"`
// Milestone is a pointer for the same reason, and because 0 is Gitea's
// "detach from its milestone" — a value somebody may well mean.
Milestone *int64 `json:"milestone,omitempty"`
State *string `json:"state,omitempty"`
Ref *string `json:"ref,omitempty"`
}
// LabelRequest is the body of a label create or edit — everything a repository
// needs to make one label.
//
// Value fields, not pointers, and every one of them is sent: Gitea 1.26 patches
// only what it is given, but an older server reads an absent field as empty and
// blanks it. A label edit is rare enough that sending the unchanged name and
// description along costs nothing and removes a way to lose them.
//
// It goes up as a request body of its own because `tea labels create` could not
// set `exclusive` — the flag that makes `type/*` behave like a single choice —
// which is the whole reason label creation went through the API rather than a
// CLI wrapper.
//
// What a label MEANS — which namespaces are exclusive, what colour a severity
// is — is not decided here. This is the shape; the taxonomy is the domain's and
// the palette is the bridge's.
type LabelRequest struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
// Set is a pointer to v, for filling the optional fields of a request. Gitea
// reads an absent key as "no opinion" and a present one as "make it this", so
// those fields are pointers and this is the shorthand that fills them.
func Set[T any](v T) *T { return &v }
-155
View File
@@ -1,155 +0,0 @@
// Package wire is the protocol: the JSON shapes a Gitea instance sends and
// takes, the identifiers that address them, and nothing else.
//
// It is a package because two layers need the same vocabulary and neither may
// import the other. internal/gitea is the transport — HTTP verbs, pagination,
// status codes, credentials — and internal/mapping is the bridge — md <-> JSON,
// pure functions, no network. Both have to name a Gitea issue, and when each
// named it with a struct of its own, every command written on top of the two
// would have had to copy a payload field by field from one spelling into the
// other. Two copies of a shape also drift: the first field only one of them
// learns is a field the other silently drops.
//
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
// filesystem, no configuration, and above all not internal/issue. That is what
// lets the transport and the bridge share it without either one landing inside
// the other's layer, and layering_test.go fails the moment it stops being true.
//
// Structs and not map[string]any, because the two representations disagreeing
// is the failure this vocabulary exists to make debuggable: a typo in a key is
// a compile error here and a silently dropped field there. Anything Gitea sends
// that is not named below is not read by anybody — decoding is lossy on
// purpose, since the tracker is not the record for anything the domain owns.
package wire
// User is whoever wrote or was assigned something.
//
// Only the login crosses this boundary — it is the one field of a Gitea user
// that means anything to a command, it is what `assignees:` holds, and a
// display name is not an identity anything can be pushed against. A transport
// that carries the rest invites somebody to use it.
type User struct {
Login string `json:"login"`
}
// Label as the tracker holds it.
//
// Color is hex. Gitea returns it without the leading `#` (`ee0701`) and accepts
// it either way; both spellings are the same color, so a comparison has to
// strip before it compares.
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
// Milestone as the tracker holds it. The domain carries its title; the id
// exists only long enough to be sent back.
type Milestone struct {
ID int64 `json:"id"`
Title string `json:"title"`
State string `json:"state"`
Description string `json:"description"`
}
// RepoRef is the repository an issue payload says it belongs to. Present on a
// dependency listing, where the answer may well be another repository.
type RepoRef struct {
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
}
// PullRequest is non-nil on a row that is a pull request rather than an issue.
// Gitea's issue endpoints return both, and `type=issues` is a filter the server
// has been known to ignore — which is why every listing re-checks it.
type PullRequest struct {
Merged bool `json:"merged"`
HTMLURL string `json:"html_url"`
}
// Issue is a tracker row: a Gitea issue as the API reports it.
//
// Timestamps stay strings. They are written into an issue's metadata verbatim
// and compared as opaque values; parsing them here would mean formatting them
// back, and a round trip through a time package is a chance to hand the store a
// different string than the tracker sent.
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
// Ref is the branch the issue is pinned to.
Ref string `json:"ref"`
// HTMLURL and UpdatedAt are the tracker's own bookkeeping and land in the
// domain's Extra untouched.
HTMLURL string `json:"html_url"`
// Comments is a count, not a thread: the thread is fetched separately and
// parked beside the issue as a sidecar.
Comments int `json:"comments"`
Labels []Label `json:"labels"`
Assignees []User `json:"assignees"`
Milestone *Milestone `json:"milestone"`
Repository *RepoRef `json:"repository"`
PullRequest *PullRequest `json:"pull_request"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// IsPullRequest reports whether this row is a pull request.
func (i *Issue) IsPullRequest() bool { return i.PullRequest != nil }
// LabelNames are the label names, in the order the tracker listed them.
func (i *Issue) LabelNames() []string {
out := make([]string, 0, len(i.Labels))
for _, l := range i.Labels {
out = append(out, l.Name)
}
return out
}
// AssigneeLogins are the assignees, as logins.
func (i *Issue) AssigneeLogins() []string {
out := make([]string, 0, len(i.Assignees))
for _, a := range i.Assignees {
out = append(out, a.Login)
}
return out
}
// MilestoneTitle is the milestone's title, or "" when there is none.
func (i *Issue) MilestoneTitle() string {
if i.Milestone == nil {
return ""
}
return i.Milestone.Title
}
// KeyIn is this issue'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 (i *Issue) KeyIn(fallback Repo) Key {
repo := fallback
if i.Repository != nil {
if r, err := ParseRepo(i.Repository.FullName); err == nil {
repo = r
}
}
return Key{Repo: repo, Number: i.Number}
}
// Comment is one entry in an issue's thread.
//
// Read only, in practice: a thread is flattened to markdown for a reader and
// nothing writes that markdown back, which is why the rendering may be as lossy
// as a reader needs.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User User `json:"user"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}