f18a633185
The plugin required `tea`, Gitea's own CLI, for everything that is not an issue: releases, pull requests, milestones, branches, actions, webhooks. That put a second binary, a second set of logins nothing here could see, and 400 lines documenting somebody else's flags outside anything this repository can test. One command over the transport that already existed removes all three. Transport: `post` — the hand-rolled request the SDK cannot express, written for the dependency endpoint — is generalized to an exported `Do`, and `post` is three lines on top of it. Same http.Client, so the same RoundTripper files the body under .kettle/payload/, the same `token …` header authenticates it, and a non-2xx is the same *APIError. It does not paginate, does not reformat the answer, and names no domain concept, so the layering test is untouched. The endpoint rule is `tea api`'s, so an endpoint table written for that tool still works — with one restriction it did not have: a full URL must be on this instance. Every request carries the project's token in a header, and a URL on another host would hand the token to whatever was typed. Command: `kettle api <endpoint>` in a new `api` group, so the generator writes plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one word. No --repo and no --login, for the reason no sync command has them: a cross-repository address is an address, and another instance is KETTLE_URL. `-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision. Scopes: a token minted for issues carries write:issue and answers 403 on the first request outside issues, naming no scope. Gitea cannot be asked what a token may do — its own token listing needs a password — so `auth add --scopes` records it, `auth list` and `config` show it, and a 403 says which category it is likely to be. Documentation only; nothing is checked against it. skills/use — the tea reference, 239 lines of it — becomes skills/api: what to ask for, which endpoints paginate, and how to write a body. Every mention of `tea` as a requirement is gone from the manifests, the READMEs, the runner and the four other skills; what survives is the back-compat with the old plugin, which is a decision and not a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
221 lines
12 KiB
Markdown
221 lines
12 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`, `Do` and the endpoint rule, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail` |
|
|
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, `ListIssues` and its budget, 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, `Do` and what it refuses |
|
|
| `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".
|
|
|
|
## `Do` — the requests the SDK cannot express
|
|
|
|
`Do(method, path, body, name)` sends one request and returns the status and the
|
|
body exactly as they came back. It was here before it was general: the dependency
|
|
endpoint takes a body the SDK's own `IssueMeta` cannot spell, so a hand-rolled
|
|
request already existed and `post` is now three lines on top of this one.
|
|
|
|
Exporting it is what lets [`kettle api`](../cmd/AGENTS.md) reach a release, a pull
|
|
request or a webhook **without this package growing a method per entity** and
|
|
without a second client holding the credentials all over again. It goes through
|
|
the same `http.Client`, so it gets the same three services as everything else: the
|
|
body is filed by the same `RoundTripper`, the same `token …` header authenticates
|
|
it, and a non-2xx is the same `*APIError`.
|
|
|
|
Three things it deliberately does not do, and each of them is a way of not lying:
|
|
|
|
- **it does not paginate** — one call is one HTTP request, and `?page=`/`?limit=`
|
|
are the caller's. The pagination below exists for a listing with a budget to
|
|
spend; a passthrough that stitched pages together would report as one answer
|
|
something that was several;
|
|
- **it does not parse or reformat the answer** — bytes in, bytes out;
|
|
- **it does not know what an issue is** — nothing about it names a domain concept,
|
|
so `TestTransportDoesNotImportTheDomain` needed no change. A generic transport is
|
|
no more a domain than a specific one was.
|
|
|
|
**The endpoint rule** is `tea api`'s, so a table of endpoints written for that tool
|
|
still works: a bare path is under `/api/v1/`, a path starting `/api/` is taken as
|
|
it stands (that is how anything outside v1 is reached), and a full URL is a full
|
|
URL — **but only on this instance**. That last clause is the one place this is
|
|
stricter than the tool it replaces, and it is not fussiness: every request carries
|
|
the project's token in a header, so a URL on another host would hand the token to
|
|
whatever was typed. Another instance is `KETTLE_URL`.
|
|
|
|
**A 403 answers with what to do about it.** Gitea scopes a token as
|
|
`<read|write>:<category>` and names no scope in the refusal, so `APIError.Error`
|
|
appends the one sentence that helps — everything outside issues is `repository`,
|
|
and `kettle auth list` shows what each login recorded. It does not guess a
|
|
specific scope, because the server did not say and a wrong guess is worse than
|
|
none.
|
|
|
|
## 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 it goes out through `Do` — same HTTP client, same payload dump, same
|
|
`*APIError`. This was the request `Do` was written for, back when it was the only
|
|
one. 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.
|