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:
@@ -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.
|
||||
Reference in New Issue
Block a user