1239fdee70
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>
126 lines
4.5 KiB
Go
126 lines
4.5 KiB
Go
// 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 (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Repo is one repository, spelled the way a tracker spells it.
|
|
type Repo struct {
|
|
Owner string
|
|
Name string
|
|
}
|
|
|
|
func (r Repo) String() string {
|
|
if r.Zero() {
|
|
return ""
|
|
}
|
|
return r.Owner + "/" + r.Name
|
|
}
|
|
|
|
// Zero reports whether this names no repository. Both halves are required:
|
|
// half a name addresses nothing.
|
|
func (r Repo) Zero() bool { return r.Owner == "" || r.Name == "" }
|
|
|
|
// ParseRepo reads owner/name.
|
|
func ParseRepo(s string) (Repo, error) {
|
|
owner, name, ok := strings.Cut(strings.TrimSpace(s), "/")
|
|
if !ok || owner == "" || name == "" {
|
|
return Repo{}, fmt.Errorf("repo %q is not owner/name", s)
|
|
}
|
|
return Repo{Owner: owner, Name: name}, nil
|
|
}
|
|
|
|
// Key is a stable cross-repo handle for one issue: owner/repo#42.
|
|
//
|
|
// It is what the ledger is keyed by and what the `gitea:` metadata field holds,
|
|
// so it has to survive being written to a file and read back — which is why it
|
|
// is a repository and a number and not a bare number. A number is ambiguous the
|
|
// moment a dependency lives in another repository, and dependencies are allowed
|
|
// to.
|
|
type Key struct {
|
|
// Repo is zero when the caller named a number and nothing else, which is
|
|
// the common case on a command line: "42" means "42 in this project's
|
|
// repository", and which repository that is, is the client's business.
|
|
Repo Repo
|
|
Number int
|
|
}
|
|
|
|
func (k Key) String() string {
|
|
if k.Repo.Zero() {
|
|
return "#" + strconv.Itoa(k.Number)
|
|
}
|
|
return fmt.Sprintf("%s#%d", k.Repo, k.Number)
|
|
}
|
|
|
|
// In returns this key with r filled in when it names no repository of its own.
|
|
func (k Key) In(r Repo) Key {
|
|
if k.Repo.Zero() {
|
|
k.Repo = r
|
|
}
|
|
return k
|
|
}
|
|
|
|
// The four spellings, as patterns.
|
|
//
|
|
// Digits and only digits after the `#`, which is the test strconv.Atoi is too
|
|
// generous to make on its own: it accepts a sign, and `owner/repo#-3` is not a
|
|
// handle anybody ever wrote. Anything that is not a key has to be recognizable
|
|
// as not a key — a hand-edited metadata line and a number are told apart here
|
|
// and nowhere else.
|
|
var (
|
|
keyURL = regexp.MustCompile(`^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$`)
|
|
keyQualified = regexp.MustCompile(`^([\w.-]+/[\w.-]+)#(\d+)$`)
|
|
keyNumber = regexp.MustCompile(`^#?(\d+)$`)
|
|
)
|
|
|
|
// ParseKey reads an issue key: 42, #42, owner/repo#42, or the issue's URL.
|
|
//
|
|
// All four spellings because all four are what somebody has in hand — a number
|
|
// from a receipt, a `#42` copied out of a body, a qualified key out of the
|
|
// ledger, a URL pasted from a browser. Refusing three of them buys nothing.
|
|
func ParseKey(s string) (Key, error) {
|
|
s = strings.TrimSpace(s)
|
|
if m := keyURL.FindStringSubmatch(s); m != nil {
|
|
n, _ := strconv.Atoi(m[3])
|
|
return Key{Repo: Repo{Owner: m[1], Name: m[2]}, Number: n}, nil
|
|
}
|
|
if m := keyQualified.FindStringSubmatch(s); m != nil {
|
|
repo, err := ParseRepo(m[1])
|
|
if err != nil {
|
|
return Key{}, err
|
|
}
|
|
n, _ := strconv.Atoi(m[2])
|
|
return Key{Repo: repo, Number: n}, nil
|
|
}
|
|
if m := keyNumber.FindStringSubmatch(s); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
return Key{Number: n}, nil
|
|
}
|
|
return Key{}, fmt.Errorf("cannot parse issue key %q — want 42, #42, owner/repo#42, or an issue URL", s)
|
|
}
|