feat: reach the rest of Gitea with kettle api, and drop tea

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>
This commit is contained in:
naudachu
2026-08-12 14:25:20 +05:00
parent e177f46510
commit f18a633185
32 changed files with 1335 additions and 519 deletions
+47 -6
View File
@@ -11,11 +11,11 @@ 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 |
| `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 |
| `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
@@ -102,6 +102,46 @@ somebody types when they want to see what was in it. `ListLabels` is read from t
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
@@ -142,9 +182,10 @@ blocking panel and refuse to close a blocked issue first.
- **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.
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