feat: publish releases with this repository's own SDK code

There is no CI: the instance has no act_runner and none is planned, so releases
are cut by hand. That makes `make check` the only thing standing between a
mistake and the tracker, and it is one command: gofmt, vet, the suite with the
cache defeated, `go mod verify`, a vendored build, and `kettle gen skills
--check`. The last one is the invariant worth having — the plugin's SKILL.md
command reference is generated from the binary's registry, so a flag that
changed cannot ship with documentation that recommends the old one.

`cli/cmd/release` publishes to Gitea using the same SDK the binary already
vendors, which is a pleasing thing to be able to say: nothing third-party
handles the artifacts. It is a second binary rather than a `kettle` subcommand
on purpose — `kettle`'s command tree is what generates the plugin's skills, so a
verb there ships to every operator, and publishing a release is build
infrastructure. It is idempotent end to end: an existing release for the tag is
reused, an asset of the same name is replaced rather than doubled, and a retried
run converges instead of duplicating.

`make release` refuses three things, each with its own message: a dirty working
tree, a TAG that is not what `git describe` reports, and a tag the remote does
not have. A release built from uncommitted code is unreproducible and nobody
finds out until they need to reproduce it.

`kettle version` reports the stamp, the toolchain and the VCS revision. The
default is `dev`, and a hand build says so and means it — a binary out of
somebody's working tree is not a release and must not claim to be one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-12 01:01:27 +05:00
parent ec0a1893b7
commit 01fb5a2703
27 changed files with 3385 additions and 266 deletions
+179
View File
@@ -0,0 +1,179 @@
# AGENTS.md — internal/gitea (TRANSPORT)
**Everything that talks to a tracker, and nothing else.** Numbers, logins, HTTP
verbs, pagination, JSON.
It does not know what an issue *is* — no sections, no acceptance criteria, no type
taxonomy — and the import graph says so in **both** directions: this package may
not reach into [`issue`](../issue/AGENTS.md), and `issue` may not reach in here.
[`mapping`](../mapping/AGENTS.md) is not imported either: it sits *above* this
package, not beside it. `TestTransportDoesNotImportTheDomain` is the check.
| file | what is in it |
|---|---|
| `client.go` | `New`, `For`, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail`, `ListIssues` and its budget |
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, dependencies |
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
| `remotemap.go` | `RemoteMap` — the number → slug ledger, and why nothing prunes it |
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate |
| `remotemap_test.go` | load, merge, save |
## What this package is, now that the SDK exists
The one place that holds the **credentials, the scratchpad and the repository this
project points at**, so no command has to. Every method is a thin wrapper, and the
wrapping is for the three things the SDK does not do:
- **every request body is filed under `.kettle/payload/`** by a `RoundTripper`, so a
retry or a post-mortem has the bytes that went out;
- **every failure comes back as `*APIError`** carrying the status *and* what the
server said, because "500" on its own has never helped anybody. Gitea answers 422
for a label that already exists, for a milestone id belonging to another
repository, and for a body missing a field — the three are told apart only by the
message, so the body travels with the code, always;
- **a listing stops when the caller has what it asked for**, which a client that
fetches whole pages into a slice cannot do.
The payload shapes are the SDK's, aliased `sdk` everywhere. The issue **keys** are
still [`wire`](../wire/AGENTS.md)'s — the SDK addresses an issue as
`(owner, repo, int64)` and never parses `owner/repo#42` out of anything.
`Fail` builds an `*APIError` out of an SDK `(response, error)` pair and is exported
for [`cmd/release`](../../cmd/release/AGENTS.md), the one caller outside this package
that builds its own client — so "the tracker said no" has one spelling in the tree.
## Building a client dials
`New` refuses a half-filled configuration **before** anything else, because building
the client dials: the SDK asks the instance for its version before it hands one
back. A missing token reported as a connection failure sends the operator to the
wrong place. Every field it checks has exactly one command that supplies it.
That handshake is also what pays for the dependency gate below, and it is why every
fake tracker in the test suite answers `/api/v1/version`.
`For(repo)` returns a copy pointed at another repository — **bookkeeping, not a
second connection**, since the SDK takes the owner and name per call. Credentials,
the negotiated version and the scratchpad are shared, which is what makes
`kettle pull owner/repo#42` cost nothing extra.
## The scratchpad
`.kettle/payload/` is a **sibling of the store, never a child**: request bodies are
debris of the transport, and a scratchpad inside a store makes `ls .kettle/issues`
lie about what exists. It is written by the `RoundTripper`, 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, leaves no
directory at all: 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.
## Listings, and the two boundaries
`ListIssues` makes one request per page, and a payload already carries the issue
body — a whole milestone costs one call per page, not one per issue.
`IssueFilter.Keep` decides whether a payload counts against `Limit`. **What Keep
means is the caller's business; this package only counts.** Two boundaries hold
whatever it decides:
- **stop at the limit** — the page after the one that completed the budget is never
requested;
- **stop at the page budget** — a filtered read scans at most `PageSlack` (4) times
the pages `Limit` would need if every payload counted. A predicate that rejects
everything must not turn a bounded read into a walk of the whole tracker. Hitting
the budget unfilled sets `IssueListing.Warning` rather than answering short in
silence — **returned rather than printed**, because the transport does not own the
operator's terminal.
`ResolveMilestone` fails **loudly**, and that is the whole point of resolving before
filtering: Gitea silently ignores a `milestones=` filter it cannot resolve and
answers with the entire backlog, so a typo would read as "your milestone has 300
issues in it". It resolves against the whole listing rather than the SDK's
`GetMilestoneByName`, which matches case-insensitively and would fold two different
milestones into one. `FindMilestone` is its quiet counterpart for a push, where a
milestone the tracker does not have means "filed without one".
`ListMilestones` returns both states, always: a milestone is closed the moment its
work is done, and a listing that hid those would fail to resolve exactly the filter
somebody types when they want to see what was in it. `ListLabels` is read from the
repository and never from a cache — a cache answers "what did we create last time",
and the question is "what does this repository have right now".
## The ledger
`.remote.json`, **inside the issue store, beside the issues it indexes** — it is
bookkeeping about issues and belongs where they are, not in the scratchpad.
**Its entries outlive the files they name, and that is deliberate.** A push deletes
an issue's file the moment the tracker confirms the write, and the entry left behind
is what makes the next pull of that number land on the same slug — so every
`depends:` that pointed at it still resolves. Nothing prunes them, not push and not
eviction, because "no file" no longer means "no such issue". A stale entry costs one
line of JSON and is corrected the next time that number is pulled.
It is a **cache, not a record**. The order of authority:
```
the tracker the issue, and the marker naming its slug
.remote.json a local number -> slug ledger, a cache of that marker
the store whatever happens to be checked out right now
```
Which is why `LoadRemoteMap` never fails — a missing, unreadable or malformed file
is an empty ledger, since refusing to run would block the very pull that would
rebuild it — and why a rebuild is a **merge and never a replacement**: the store is
a subset of what the ledger knows, so starting from the files alone would throw away
every entry it cannot see. Load, add what the files say, save.
`Save` is the one write here allowed to create the store, and only because of when
it happens: the ledger is written the instant the tracker confirms a push and
**before** the local file is deleted, so failing it over a missing directory would
lose the slug at exactly the moment the local copy stops being the record.
## 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.
- **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 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 URL names the blocked issue and
the body the blocker, which is the direction `Dependencies` reads back.
- **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 older instance 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** — the Python version swallowed both, and "the server said no"
and "there was no server" are different answers.
A link that already exists answers 409, so callers pre-filter with `DependencyKeys`
and treat a failure here as a note rather than an abort: one missing cross-link must
not undo a push that has already created issues.
## Two Gitea quirks worth knowing before touching anything
- **`EditIssue` carries no labels.** Gitea's edit endpoint takes none and neither
does the SDK's option struct, so an issue whose labels changed needs `SetLabels`
after it — `push` makes that call and says which names moved.
- **A create can silently drop labels handed to it.** `SetLabels` re-applies them
rather than trusting the echo.
## Keeping this file true
- **Scope:** every `.go` file here — the client, the endpoints it wraps, the
scratchpad, the ledger, and the quirks that shape them.
- **Update it when** a method is added or removed, a request stops going through the
SDK or starts to, the page budget or the version gate changes, the ledger's format
or location changes, or a new Gitea quirk is worked around — a workaround with no
written reason is a workaround somebody deletes.
- **Do not** explain what a field *means* to an issue. That is
[`mapping`](../mapping/AGENTS.md)'s and [`issue`](../issue/AGENTS.md)'s.
+8
View File
@@ -197,6 +197,14 @@ func StatusIs(err error, status int) bool {
return errors.As(err, &apiErr) && apiErr.Status == status
}
// Fail is fail, exported for the one caller outside this package that needs it.
//
// `cmd/release` builds its own SDK client — see its package doc for why a build
// tool must not use this one — but a failure it reports has to name a status
// and quote what the server said in the same words a push does. One function,
// so the two spellings of "the tracker said no" cannot drift apart.
func Fail(resp *sdk.Response, err error) error { return fail(resp, err) }
// fail turns one SDK call's (response, error) pair into this package's error.
//
// BOTH HALVES OR NEITHER. The SDK reads the response body to build its error