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:
+110
-27
@@ -2,8 +2,7 @@
|
||||
|
||||
`kettle` is a globally installed binary. It owns everything that used to be a
|
||||
Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the
|
||||
store lives, who this machine is, and (once the transport lands) how issues move
|
||||
to and from Gitea.
|
||||
store lives, who this machine is, and how issues move to and from Gitea.
|
||||
|
||||
The plugin keeps what only a plugin can carry — the rules an operator states and
|
||||
a binary cannot enforce. Everything mechanical is here.
|
||||
@@ -24,15 +23,37 @@ A compiled binary answers all three by construction. There is one walk
|
||||
(`internal/project`), it is imported rather than re-derived, and the layering
|
||||
rule is a build graph a test walks.
|
||||
|
||||
## One dependency
|
||||
## Two dependencies
|
||||
|
||||
`gopkg.in/yaml.v3`, vendored, and that is the whole list. Everything else is the
|
||||
standard library: the transport is plain `net/http` against a documented REST
|
||||
API, and the CLI has no cobra — commands are values in a registry, which is what
|
||||
`gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea`, both vendored, and that is the
|
||||
whole list — eight modules once the SDK's own are counted, 2.3 MB of `vendor/`.
|
||||
The CLI still has no cobra: commands are values in a registry, which is what
|
||||
lets the plugin's SKILL.md files be generated from the same struct that holds
|
||||
the code.
|
||||
|
||||
`vendor/` is committed, so a build needs no network.
|
||||
`vendor/` is committed, so a build needs no network. **`go.mod` says `go 1.26`**,
|
||||
which the SDK requires; that is now the minimum for anybody building this binary.
|
||||
|
||||
The transport was hand-rolled `net/http` against the REST API and is now the
|
||||
official SDK. What that bought:
|
||||
|
||||
- **the payload shapes are one vocabulary, maintained by the people who maintain
|
||||
the server.** They used to be ours, in `internal/wire`, and every field Gitea
|
||||
learned was a field somebody here had to notice.
|
||||
- **the server's version, for free.** The SDK negotiates it when a client is
|
||||
built, which is what lets the transport answer "does this instance have issue
|
||||
dependencies?" from the version instead of guessing from a status code — see
|
||||
`dependenciesSince`.
|
||||
|
||||
What it cost is written down where it happened: the shapes come with an HTTP
|
||||
client attached (see the layering note below), a run makes one extra request
|
||||
before it does anything (the version handshake), and Gitea's issue **edit**
|
||||
endpoint carries no labels, so an issue whose labels changed needs a second call
|
||||
— `push` makes it and says so.
|
||||
|
||||
The SDK is imported as `sdk` everywhere, so one type has one spelling across the
|
||||
tree. `internal/gitea` is the package named `gitea` and the SDK is `sdk` inside
|
||||
it; the same alias holds in `internal/mapping` and `internal/cmd`.
|
||||
|
||||
## Layers
|
||||
|
||||
@@ -50,10 +71,10 @@ internal/cmd the command tree: flags, receipts, exit codes
|
||||
│ │ request, pagination, payload dumps,
|
||||
│ │ the number -> slug ledger
|
||||
│ ▼
|
||||
├────────────────────► internal/wire PROTOCOL: the JSON shapes and the
|
||||
│ ▲ identifiers. Imports nothing.
|
||||
│ │
|
||||
└──► internal/mapping ─────┘ BRIDGE: md <-> those shapes, pure,
|
||||
├────────────────────► internal/wire ADDRESSES: Repo and Key, and the
|
||||
│ ▲ parsing that reads them. Imports
|
||||
│ │ nothing.
|
||||
└──► internal/mapping ─────┘ BRIDGE: md <-> the SDK's payloads,
|
||||
│ no I/O; label colours live here
|
||||
▼
|
||||
internal/issue DOMAIN what an issue is: format, taxonomy, validation,
|
||||
@@ -65,27 +86,49 @@ internal/project ROOT which directory is the project, and every path
|
||||
depends on nothing
|
||||
```
|
||||
|
||||
Both the transport and the bridge also import `code.gitea.io/sdk/gitea`, which
|
||||
is where the payloads now live. The arrow that used to point at `internal/wire`
|
||||
for them points there instead.
|
||||
|
||||
Read it bottom-up and each layer knows strictly less about trackers than the one
|
||||
above it. Four tests hold the line, and each fails on a real mistake rather than
|
||||
on a naming convention:
|
||||
above it. Four rules hold the line, seven tests between them, and each fails on
|
||||
a real mistake rather than on a naming convention:
|
||||
|
||||
- `internal/issue` may import `internal/project` and the standard library, and
|
||||
nothing else. One test walks `go list -deps` and fails on any path with a dot
|
||||
in its first element — which is also what keeps yaml out of the domain — and
|
||||
another names `net/http`, `net`, `os/exec` and `encoding/json`, standard
|
||||
library the first test would not catch.
|
||||
in its first element — which is also what keeps yaml AND the SDK out of the
|
||||
domain — and another names `net/http`, `net`, `os/exec` and `encoding/json`,
|
||||
standard library the first test would not catch. **Untouched by the migration
|
||||
to the SDK, and that is the point: the domain did not notice it happened.**
|
||||
- `internal/wire` imports only the standard library, checked the same two ways.
|
||||
- `internal/gitea` must not import `internal/issue` **or** `internal/mapping`:
|
||||
the transport knows numbers, logins, HTTP and JSON, and none of what they mean.
|
||||
- `internal/mapping` performs no I/O and imports neither the transport nor the
|
||||
configuration.
|
||||
- `internal/mapping` reaches for nothing but the domain and the SDK — checked on
|
||||
its DIRECT imports, with `os`, `net/http` and `internal/gitea` named — and a
|
||||
second test greps its sources for `time.Now`.
|
||||
|
||||
`wire` exists because Go needs the JSON shapes to be one type. The transport and
|
||||
the bridge were written in parallel and each invented its own `Issue`, `Label`,
|
||||
`Milestone` and `Comment`; every command on top would then have copied fields
|
||||
from one struct into the other by hand, which is two vocabularies for one thing —
|
||||
exactly what this layering exists to prevent. Python did not have the problem
|
||||
because it passed dicts.
|
||||
**That fourth rule was stronger before the SDK, and the trade is deliberate.**
|
||||
The payload shapes lived in `internal/wire`, a package that imported the
|
||||
standard library and nothing else, so "the bridge cannot reach a transport" was
|
||||
a fact about the import graph: there was nothing in its dependency closure that
|
||||
could open a socket. `code.gitea.io/sdk/gitea` is a client and a set of types in
|
||||
one package, so importing the types imports the client, and a test that walked
|
||||
the closure would now be asserting something false. What is still true, and what
|
||||
the test now says, is that **mapping performs no I/O** — no `os`, no `net/http`,
|
||||
no transport, no configuration, no clock. `time` is allowed where it was not,
|
||||
because the SDK hands over a `time.Time` and somebody has to format it back into
|
||||
the string an issue file holds; the clock itself is still the caller's, and the
|
||||
grep for `time.Now` is what says so.
|
||||
|
||||
`wire` used to exist because Go needs the JSON shapes to be one type — the
|
||||
transport and the bridge were written in parallel and each invented its own
|
||||
`Issue`, `Label`, `Milestone` and `Comment`. The SDK settles that argument for
|
||||
the shapes. **What survives is addressing**, which the SDK has no answer for at
|
||||
all: it takes an owner, a name and an `int64`, and never parses. `42`, `#42`,
|
||||
`owner/repo#42` and an issue URL are four spellings of one address, all four are
|
||||
what somebody has in hand, and `wire.Key` is what the ledger is keyed by and
|
||||
what the `gitea:` metadata field holds. So `wire` keeps `Repo`, `Key`, their
|
||||
parsing and their tests, and lost the payloads.
|
||||
|
||||
If a tracker concept — an issue number, a login, an HTTP call, a label colour —
|
||||
shows up in `internal/issue`, it is in the wrong place.
|
||||
@@ -153,7 +196,14 @@ instead of the tracker, which is why it lives in the domain.
|
||||
|
||||
`.kettle/payload/` is a sibling, never a child: request bodies are debris of the
|
||||
transport, and a scratchpad inside a store makes `ls .kettle/issues` lie about
|
||||
what exists.
|
||||
what exists. It is written by an `http.RoundTripper` installed on the SDK's
|
||||
client, so **every** request with a body is filed and not only the ones a call
|
||||
site remembered to name — a name only decides what the file is called. A run
|
||||
that sends nothing, which includes every read-only command, still leaves no
|
||||
directory: the first write creates it. The dump is the same JSON the wire
|
||||
carried, re-indented and with `<`, `>` and `&` left alone, because the SDK
|
||||
marshals with encoding/json's escaping and a dump nobody can read is a dump
|
||||
nobody reads.
|
||||
|
||||
`kettle init` migrates older layouts in, oldest first — `tmp/issues` and then
|
||||
`.tea/issues` — and each is a **move**. A store left behind at an old path is a
|
||||
@@ -178,6 +228,12 @@ so the harness's own value would point every fixture at this repository.
|
||||
Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so
|
||||
a test run can neither read nor overwrite the developer's own tokens.
|
||||
|
||||
**Every fake tracker answers `/api/v1/version`**, because building a client asks
|
||||
for it: the SDK will not hand one back until the instance has said what it is,
|
||||
and that answer is what the dependency gate is decided on later. The fakes say
|
||||
1.26.1. One says 1.19.4, and that is a test — an instance too old for the issue
|
||||
dependency endpoints is answered from its version, with no request made.
|
||||
|
||||
## The round trip
|
||||
|
||||
`push` and `pull` are the two halves of one rule, and the rule is that **the
|
||||
@@ -185,6 +241,10 @@ store holds what has not left this machine.**
|
||||
|
||||
A successful push deletes `<id>.md` and every sidecar under that slug, on create
|
||||
and on `--update` alike, and prints the number and URL the issue now lives at.
|
||||
An `--update` can take one extra request with it: Gitea's edit endpoint carries
|
||||
no labels, so when the answer's label set and the issue's disagree — a label
|
||||
added or removed locally, or one a create silently dropped — the whole set goes
|
||||
up in a PUT, and a warning on stderr says which names moved.
|
||||
The deletion happens **only after a confirmed tracker response and only after the
|
||||
number -> slug ledger has been written** — network down, non-2xx, or an answer
|
||||
that does not carry the right number, and the file stays where it is while the
|
||||
@@ -219,13 +279,36 @@ There is also no `--login` and no `--repo` on any sync command bar `labels`.
|
||||
Which login a project runs under, and which repository its issues belong to, are
|
||||
facts about the project, stated once by `kettle init`. A cross-repository address
|
||||
is still an address: `kettle pull owner/repo#42` re-points the client for that
|
||||
one call.
|
||||
one call — `Client.For(repo)`, which is bookkeeping and not a second connection,
|
||||
because the SDK takes the owner and the name per call. The credentials, the
|
||||
negotiated version and the scratchpad come along.
|
||||
|
||||
## Issue dependencies
|
||||
|
||||
The one endpoint with a story. `depends:` becomes a native Gitea link, which is
|
||||
what makes the tracker show the blocking panel and refuse to close a blocked
|
||||
issue first, and it is read back the same way on a pull.
|
||||
|
||||
- **Reading** goes through the SDK (`ListIssueDependencies`).
|
||||
- **Writing does not.** Gitea's own `IssueMeta` is `{index, owner, repo}` and has
|
||||
been since the endpoint existed; the SDK's is `{index}`, which can only ever
|
||||
link inside one repository, and a `depends:` entry is allowed to live
|
||||
somewhere else. So one hand-rolled POST survives, through the same HTTP client
|
||||
as everything else — same payload dump, same `*APIError`.
|
||||
- **The version gates both.** The routes are absent from Gitea 1.19 and present
|
||||
in 1.20, checked against the release tags themselves, so an instance older
|
||||
than that is answered from the version it already gave us rather than from a
|
||||
404 — which on an old server is also what "no such issue" looks like.
|
||||
- **A tracker that answers with a status still means "no dependencies here"**,
|
||||
because an instance that HAS the endpoint can still have them turned off for a
|
||||
repository, and a pull must bring the issue back either way. A dead connection
|
||||
is not that answer and never was.
|
||||
|
||||
## Status
|
||||
|
||||
Done and tested: all seven packages, and the commands `init`, `auth`, `config`,
|
||||
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
|
||||
`comment`, `close`, `labels`, `sync-evict`. 89 tests.
|
||||
`comment`, `close`, `labels`, `sync-evict`. 99 tests.
|
||||
|
||||
Not done: the plugin still ships the Python scripts and the guard hook, and
|
||||
still resolves `.tea/`. Rewiring `plugins/tea` onto this binary — and generating
|
||||
|
||||
Reference in New Issue
Block a user