01fb5a2703
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>
180 lines
9.9 KiB
Markdown
180 lines
9.9 KiB
Markdown
# 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.
|