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:
@@ -7,7 +7,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kettle",
|
"name": "kettle",
|
||||||
"source": "./plugins/kettle",
|
"source": "./plugins/kettle",
|
||||||
"description": "Issues as local markdown, driven by the kettle binary: /kettle:init makes a directory a project, /kettle:issue works on issues offline (format, validation, checkboxes, dependency graph), /kettle:sync moves them to and from Gitea, /kettle:auth manages the credential a project runs under, /kettle:use is the tea CLI reference for the Gitea entities kettle does not cover, and the kettle-runner subagent executes batches on a cheap model. Needs the kettle binary on PATH — build it from cli/ in this repository (Go 1.26)."
|
"description": "Issues as local markdown, driven by the kettle binary: /kettle:init makes a directory a project, /kettle:issue works on issues offline (format, validation, checkboxes, dependency graph), /kettle:sync moves them to and from Gitea, /kettle:auth manages the credential a project runs under, /kettle:api reaches everything else Gitea has — pull requests, releases, tags, milestones, actions, webhooks — through that same login, and the kettle-runner subagent executes batches on a cheap model. No other CLI is needed; the kettle binary must be on PATH — build it from cli/ in this repository (Go 1.26)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tdl",
|
"name": "tdl",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Where to go from here, and each of these directories documents itself:
|
|||||||
|
|
||||||
| directory | what it is |
|
| directory | what it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`cli/`](cli/AGENTS.md) | one Go module, two binaries: `kettle`, which owns every mechanical thing about issues — the format, the store, the credentials, the tracker — and [`cmd/release`](cli/cmd/release/AGENTS.md), which publishes this repository's own releases. `make check` is the gate; there is no CI here |
|
| [`cli/`](cli/AGENTS.md) | one Go module, two binaries: `kettle`, which owns this project's connection to its tracker — the format, the store, the credentials, the transport, and through `kettle api` every Gitea entity that has no command of its own — and [`cmd/release`](cli/cmd/release/AGENTS.md), which publishes this repository's own releases. `make check` is the gate; there is no CI here |
|
||||||
| [`plugins/`](plugins/AGENTS.md) | what a plugin is here, and what the catalog entry has to match |
|
| [`plugins/`](plugins/AGENTS.md) | what a plugin is here, and what the catalog entry has to match |
|
||||||
| [`plugins/kettle/`](plugins/kettle/AGENTS.md) | the plugin that wraps the binary: skills, the runner subagent, the hooks |
|
| [`plugins/kettle/`](plugins/kettle/AGENTS.md) | the plugin that wraps the binary: skills, the runner subagent, the hooks |
|
||||||
| [`plugins/tdl/`](plugins/tdl/AGENTS.md) | the Three Dots Labs Go rule set — no binary, no state, just rules and templates |
|
| [`plugins/tdl/`](plugins/tdl/AGENTS.md) | the Three Dots Labs Go rule set — no binary, no state, just rules and templates |
|
||||||
@@ -23,6 +23,14 @@ worth carrying: **a binary holds what can be enforced, a plugin holds what can
|
|||||||
only be stated.** Anything mechanical belongs in Go where a test can hold it
|
only be stated.** Anything mechanical belongs in Go where a test can hold it
|
||||||
down; anything that is a judgement an operator makes belongs in a SKILL.md.
|
down; anything that is a judgement an operator makes belongs in a SKILL.md.
|
||||||
|
|
||||||
|
`kettle api` is that rule applied to an external dependency rather than to a
|
||||||
|
script. Reaching a release or a pull request used to mean requiring `tea`, which
|
||||||
|
put the credentials, the request and the flags outside anything this repository
|
||||||
|
could test — so the mechanical half came in as one command over the transport
|
||||||
|
that already existed, and what stayed in the plugin is the half that was never
|
||||||
|
mechanical: which endpoint answers the question, and whether the thing should be
|
||||||
|
deleted at all.
|
||||||
|
|
||||||
## The AGENTS.md convention
|
## The AGENTS.md convention
|
||||||
|
|
||||||
**Every directory with a story documents itself, in that directory.** This file
|
**Every directory with a story documents itself, in that directory.** This file
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ that answers `command not found: kettle` is telling you exactly this.
|
|||||||
|
|
||||||
| Plugin | Commands | What it does |
|
| Plugin | Commands | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`kettle`](plugins/kettle) | `/kettle:init` `/kettle:auth` `/kettle:issue` `/kettle:sync` `/kettle:use` `/kettle:project` | Issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; the `kettle` binary does the work, and each skill's command reference is generated from the binary's own command registry so it cannot drift |
|
| [`kettle`](plugins/kettle) | `/kettle:init` `/kettle:auth` `/kettle:issue` `/kettle:sync` `/kettle:api` `/kettle:project` | Issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; the `kettle` binary does the work, and each skill's command reference is generated from the binary's own command registry so it cannot drift |
|
||||||
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against nine categories of CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
|
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against nine categories of CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -53,8 +53,9 @@ that answers `command not found: kettle` is telling you exactly this.
|
|||||||
# The `kettle` CLI
|
# The `kettle` CLI
|
||||||
|
|
||||||
A single Go binary that keeps a project's issues as flat markdown files and moves
|
A single Go binary that keeps a project's issues as flat markdown files and moves
|
||||||
them to and from Gitea when you say so. **Everything outside the `sync` group works
|
them to and from Gitea when you say so — and, with `kettle api`, reaches every
|
||||||
with the network off.** Two direct dependencies, vendored, no cobra, seven
|
other Gitea entity under the same login, so nothing here needs a second CLI.
|
||||||
|
**Everything outside the `sync` and `api` groups works with the network off.** Two direct dependencies, vendored, no cobra, seven
|
||||||
internal packages with one direction of imports — see
|
internal packages with one direction of imports — see
|
||||||
[`cli/AGENTS.md`](cli/AGENTS.md).
|
[`cli/AGENTS.md`](cli/AGENTS.md).
|
||||||
|
|
||||||
@@ -93,6 +94,12 @@ internal packages with one direction of imports — see
|
|||||||
| `kettle labels` | creates the canonical `type/*` and `severity/*` labels in a repository, `exclusive` flag and all |
|
| `kettle labels` | creates the canonical `type/*` and `severity/*` labels in a repository, `exclusive` flag and all |
|
||||||
| `kettle sync-evict` | refreshes state from the tracker first, then evicts what is closed |
|
| `kettle sync-evict` | refreshes state from the tracker first, then evicts what is closed |
|
||||||
|
|
||||||
|
**api** — everything else Gitea has, reached directly
|
||||||
|
|
||||||
|
| command | what it does |
|
||||||
|
|---|---|
|
||||||
|
| `kettle api` | one request to an endpoint this binary has no command for — pull requests, releases, tags, milestones, branches, actions, webhooks — under the login the project already pins. One invocation is one request; it does not paginate, does not reformat the answer, and `-X DELETE` needs `--yes` |
|
||||||
|
|
||||||
`kettle help` prints the tree; `kettle help <command>` prints one command in full —
|
`kettle help` prints the tree; `kettle help <command>` prints one command in full —
|
||||||
flags, defaults and worked examples. Flags may be typed after positional arguments.
|
flags, defaults and worked examples. Flags may be typed after positional arguments.
|
||||||
|
|
||||||
|
|||||||
+23
-7
@@ -1,9 +1,23 @@
|
|||||||
# AGENTS.md — the kettle CLI
|
# AGENTS.md — the kettle CLI
|
||||||
|
|
||||||
`kettle` is a globally installed binary. It owns everything mechanical about
|
`kettle` is a globally installed binary. **It owns this project's connection to
|
||||||
issues: what an issue is, where the store lives, who this machine is, and how
|
its tracker** — the credentials, the transport, the payload scratchpad — and
|
||||||
issues move to and from Gitea. It replaced a set of Python scripts that used to
|
issues are its main subject but no longer its only one: what an issue is, where
|
||||||
ship inside the plugin.
|
the store lives, who this machine is, how issues move to and from Gitea, and,
|
||||||
|
through `kettle api`, every other Gitea entity that has no command of its own.
|
||||||
|
It replaced a set of Python scripts that used to ship inside the plugin.
|
||||||
|
|
||||||
|
That last clause is a deliberate widening and it is worth being straight about.
|
||||||
|
"Issues and nothing else" was the line until two things crossed it: `cmd/release`,
|
||||||
|
which publishes this repository's own releases, and `kettle api`, which exists
|
||||||
|
because the alternative was requiring `tea` — a second CLI with a second set of
|
||||||
|
logins that nothing here could see, documented in 400 lines of somebody else's
|
||||||
|
flag reference that nothing here could check. One door for every request is worth
|
||||||
|
more than a slogan: the token is held in one place, every body lands in one
|
||||||
|
scratchpad, and no skill has to explain which tool is authenticated as whom.
|
||||||
|
What has *not* widened is the domain — `internal/issue` still knows nothing about
|
||||||
|
trackers, and `api` is transport plus a command, touching neither it nor
|
||||||
|
`internal/mapping`.
|
||||||
|
|
||||||
The plugin keeps what only a plugin can carry — the rules an operator states and
|
The plugin keeps what only a plugin can carry — the rules an operator states and
|
||||||
a binary cannot enforce. Everything else is here.
|
a binary cannot enforce. Everything else is here.
|
||||||
@@ -154,7 +168,9 @@ would run under a login the model picked instead of the operator. That whole
|
|||||||
apparatus is gone. The binary holds its own credentials and reads the login out
|
apparatus is gone. The binary holds its own credentials and reads the login out
|
||||||
of the project's own configuration, so there is no argument to police and no way
|
of the project's own configuration, so there is no argument to police and no way
|
||||||
for the transport and the guard to disagree — the failure the hook existed to
|
for the transport and the guard to disagree — the failure the hook existed to
|
||||||
catch is not expressible any more.
|
catch is not expressible any more. `kettle api` did not put it back: it takes an
|
||||||
|
endpoint and a body, never a login, and a full URL on another host is refused
|
||||||
|
rather than sent with this project's token attached.
|
||||||
|
|
||||||
There is also no `--login` and no `--repo` on any sync command bar `labels`.
|
There is also no `--login` and no `--repo` on any sync command bar `labels`.
|
||||||
A cross-repository address is still an address: `kettle pull owner/repo#42`
|
A cross-repository address is still an address: `kettle pull owner/repo#42`
|
||||||
@@ -208,8 +224,8 @@ reason.
|
|||||||
|
|
||||||
Done and tested: every package in the table above, and the commands `init`, `auth`,
|
Done and tested: every package in the table above, and the commands `init`, `auth`,
|
||||||
`config`, `gen`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
|
`config`, `gen`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
|
||||||
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict` — plus `cmd/release`,
|
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict`, `api` — plus
|
||||||
against a fake Gitea.
|
`cmd/release`, against a fake Gitea.
|
||||||
|
|
||||||
The plugin is rewired: it lives at `plugins/kettle`, ships no Python domain code
|
The plugin is rewired: it lives at `plugins/kettle`, ships no Python domain code
|
||||||
and no guard hook, and its command reference is generated from this registry.
|
and no guard hook, and its command reference is generated from this registry.
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ anything — which is how the doc generator reads them.
|
|||||||
|
|
||||||
**The tree is flat.** `kettle new`, not `kettle issue new`: an agent pays for every
|
**The tree is flat.** `kettle new`, not `kettle issue new`: an agent pays for every
|
||||||
token of every invocation, and the grouping that matters for reading is carried in
|
token of every invocation, and the grouping that matters for reading is carried in
|
||||||
`Group` and only shows up in the docs. Three groups, in presentation order:
|
`Group` and only shows up in the docs. Four groups, in presentation order:
|
||||||
`project`, `issue`, `sync`.
|
`project`, `issue`, `sync`, `api`.
|
||||||
|
|
||||||
| file | what is in it |
|
| file | what is in it |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -51,10 +51,16 @@ token of every invocation, and the grouping that matters for reading is carried
|
|||||||
| `init.go` `auth.go` `config.go` `version.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
|
| `init.go` `auth.go` `config.go` `version.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
|
||||||
| `new.go` `check.go` `ac.go` `tree.go` `index.go` `evict.go` | group `issue` — no network in any of them |
|
| `new.go` `check.go` `ac.go` `tree.go` `index.go` `evict.go` | group `issue` — no network in any of them |
|
||||||
| `pull.go` `push.go` `remote.go` `comment.go` `close.go` `labels.go` `evict_sync.go` | group `sync` |
|
| `pull.go` `push.go` `remote.go` `comment.go` `close.go` `labels.go` `evict_sync.go` | group `sync` |
|
||||||
|
| `api.go` | group `api`, alone in it: one request to an endpoint nothing here wraps |
|
||||||
| `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess |
|
| `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess |
|
||||||
| `sync_pull_test.go` `sync_write_test.go` | the tracker halves, against fake servers |
|
| `sync_pull_test.go` `sync_write_test.go` `api_test.go` | the tracker halves, against fake servers |
|
||||||
| `gen_test.go` | the generator: determinism, the region splice, the missing-marker refusal |
|
| `gen_test.go` | the generator: determinism, the region splice, the missing-marker refusal |
|
||||||
|
|
||||||
|
The fourth group is one command and was still worth naming, because a group is a
|
||||||
|
skill directory over in the plugin: `api` is a subject somebody loads on its own —
|
||||||
|
which endpoint, and does it paginate — and folding it into `sync` would have put
|
||||||
|
"how do I cut a release" behind a skill about the issue round trip.
|
||||||
|
|
||||||
## Three conventions every command follows
|
## Three conventions every command follows
|
||||||
|
|
||||||
**Flags may come after positionals.** The standard `flag` package stops parsing at
|
**Flags may come after positionals.** The standard `flag` package stops parsing at
|
||||||
@@ -84,6 +90,20 @@ login a project runs under is a fact about the project, stated once by
|
|||||||
`kettle init`. That the two could disagree is what the Python version needed a
|
`kettle init`. That the two could disagree is what the Python version needed a
|
||||||
`PreToolUse` hook to police.
|
`PreToolUse` hook to police.
|
||||||
|
|
||||||
|
`api` keeps that rule and needs no flag to: a cross-repository address is an
|
||||||
|
address, so `repos/other-owner/other-repo/releases` is simply a path with nothing
|
||||||
|
to substitute — `{owner}` and `{repo}` are filled in only where they are spelled.
|
||||||
|
Another **instance** is `KETTLE_URL`/`KETTLE_TOKEN`, and a full URL pointing at a
|
||||||
|
host that is not this project's is refused by the transport rather than sent with
|
||||||
|
the token attached. It also resolves the store it never reads, exactly as `labels`
|
||||||
|
does, so "there is no project here" fails the same way for every command that
|
||||||
|
talks to a tracker.
|
||||||
|
|
||||||
|
**`-X DELETE` needs `--yes`.** The only gate of its kind in the tree, and it is
|
||||||
|
here because this is the only command that can delete something that is not an
|
||||||
|
issue — a release, a tag, a branch — from an argument. A flag typed on purpose is
|
||||||
|
an operator's decision; everything else about the request goes out as spelled.
|
||||||
|
|
||||||
`--out` is the one flag almost every command has, and an explicit one is used
|
`--out` is the one flag almost every command has, and an explicit one is used
|
||||||
**exactly as typed**: a relative `--out` stays relative to the working directory,
|
**exactly as typed**: a relative `--out` stays relative to the working directory,
|
||||||
because that is what the operator asked for.
|
because that is what the operator asked for.
|
||||||
@@ -169,7 +189,10 @@ useful: it writes nothing and exits 1 when anything on disk differs, which is wh
|
|||||||
pre-commit hook or a CI step calls, and it wins over `--dry-run`.
|
pre-commit hook or a CI step calls, and it wins over `--dry-run`.
|
||||||
|
|
||||||
One file per **group**, so adding a group here adds a skill directory over there;
|
One file per **group**, so adding a group here adds a skill directory over there;
|
||||||
name one only when it is a subject somebody would load on its own. A command with no
|
name one only when it is a subject somebody would load on its own. `api` was
|
||||||
|
added exactly that way, and the first `gen skills` run after it wrote a stub whose
|
||||||
|
`description:` said TODO — a stub is not shippable, and filling that line in by
|
||||||
|
hand is the last step of adding a group, not an optional one. A command with no
|
||||||
`Group` is in no skill and the run says so. A `Long` or `Example` that spells a
|
`Group` is in no skill and the run says so. A `Long` or `Example` that spells a
|
||||||
region marker out in full is a hard error — the generated block would end inside
|
region marker out in full is a hard error — the generated block would end inside
|
||||||
itself.
|
itself.
|
||||||
@@ -180,8 +203,8 @@ itself.
|
|||||||
three conventions, the round trip, the generator. The file table names every
|
three conventions, the round trip, the generator. The file table names every
|
||||||
source file in this directory.
|
source file in this directory.
|
||||||
- **Update it when** a command file is added or removed, a group is added, a shared
|
- **Update it when** a command file is added or removed, a group is added, a shared
|
||||||
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, or the
|
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, a command
|
||||||
push/pull ordering guarantees change.
|
gains a confirmation gate, or the push/pull ordering guarantees change.
|
||||||
- **Do not** copy a flag list or a command's `Long` text here. `kettle help <name>`
|
- **Do not** copy a flag list or a command's `Long` text here. `kettle help <name>`
|
||||||
and the generated SKILL.md blocks are the two places that exist for it, and a
|
and the generated SKILL.md blocks are the two places that exist for it, and a
|
||||||
third would be the one that drifts.
|
third would be the one that drifts.
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiMethods is what this command will send. Not a defence against a typo so
|
||||||
|
// much as against a shell: an unquoted endpoint that swallowed a word must not
|
||||||
|
// be sent as a verb the server then answers 405 to.
|
||||||
|
var apiMethods = map[string]bool{
|
||||||
|
http.MethodGet: true,
|
||||||
|
http.MethodPost: true,
|
||||||
|
http.MethodPut: true,
|
||||||
|
http.MethodPatch: true,
|
||||||
|
http.MethodDelete: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
register(&Command{
|
||||||
|
Name: "api",
|
||||||
|
Group: GroupAPI,
|
||||||
|
Args: "<endpoint>",
|
||||||
|
Short: "one request to this project's Gitea, for everything that is not an issue",
|
||||||
|
Long: `Releases, pull requests, milestones, branches, tags, actions, webhooks,
|
||||||
|
notifications: everything Gitea has that this binary has no command for. One
|
||||||
|
invocation is ONE request — the credentials, the repository and the payload
|
||||||
|
scratchpad are the ones this project already resolved, so there is nothing to
|
||||||
|
configure and no second tool to log in.
|
||||||
|
|
||||||
|
THE ENDPOINT IS SPELLED THE WAY GITEA'S OWN DOCUMENTATION SPELLS IT. A bare path
|
||||||
|
is taken as relative to ` + "`/api/v1/`" + `; a path that already begins ` + "`/api/`" + ` is sent as it
|
||||||
|
stands, which is how anything outside v1 is reached; a full URL is allowed only
|
||||||
|
on the instance this project points at, because every request here carries the
|
||||||
|
project's token in a header and a URL somewhere else would hand that token over.
|
||||||
|
` + "`{owner}`" + ` and ` + "`{repo}`" + ` are filled in from the project's configuration. Quote an
|
||||||
|
endpoint that contains ? or & or the shell will take it apart.
|
||||||
|
|
||||||
|
ANOTHER REPOSITORY NEEDS NO FLAG — write its address into the path
|
||||||
|
(` + "`repos/other-owner/other-repo/releases`" + `) and nothing is substituted. There is no
|
||||||
|
--repo and no --login here for the same reason there is none on push or pull:
|
||||||
|
which login a project runs under is a fact about the project. Another INSTANCE
|
||||||
|
is KETTLE_URL and KETTLE_TOKEN, which is also what a CI run uses.
|
||||||
|
|
||||||
|
THE ANSWER IS THE SERVER'S BYTES ON STDOUT, unparsed and unreformatted — pipe it
|
||||||
|
to jq, redirect it to a file. There is no flag that names an output file: in
|
||||||
|
this tree --out is the issue store, and one word meaning two things is exactly
|
||||||
|
the trap the tool this replaces set with an -o that wrote a file called "json".
|
||||||
|
|
||||||
|
IT DOES NOT PAGINATE. One call is one request, so a listing answers with one
|
||||||
|
page: ask for the next with ?page=2, and for a bigger one with ?limit=50 (the
|
||||||
|
server's own default is 30, its maximum is usually 50). A passthrough that
|
||||||
|
stitched pages together silently would report as one answer something that was
|
||||||
|
several.
|
||||||
|
|
||||||
|
ISSUES ARE NOT THIS COMMAND'S JOB even though it can reach them. An issue read
|
||||||
|
this way arrives as a full JSON payload — every comment, every label object,
|
||||||
|
every URL — which is what /kettle:issue and /kettle:sync exist to keep out of a
|
||||||
|
context window. Use pull, push, comment and close.
|
||||||
|
|
||||||
|
A 403 here is usually the token rather than the request: a token minted for
|
||||||
|
issues carries write:issue, and releases, pull requests, branches and tags are
|
||||||
|
all under repository. ` + "`kettle auth list`" + ` shows what each login records.
|
||||||
|
|
||||||
|
-X DELETE NEEDS --yes. Everything else goes through as typed; a deletion does
|
||||||
|
not, because a flag typed on purpose is an operator's decision and the URL of a
|
||||||
|
release is one character away from the URL of the wrong release.
|
||||||
|
|
||||||
|
What it cannot do: an upload. Release attachments are multipart/form-data and
|
||||||
|
this sends JSON — the release tooling in cmd/release does those.`,
|
||||||
|
Examples: []Example{
|
||||||
|
{"kettle api repos/{owner}/{repo}/releases", "the latest page of releases, as JSON"},
|
||||||
|
{"kettle api user", "who this project's token belongs to"},
|
||||||
|
{`kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50'`, "quote anything with ? or & in it"},
|
||||||
|
{"kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases", "a body from a file; POST is implied"},
|
||||||
|
{"kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments", "a small body without a file"},
|
||||||
|
{"kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12", "a deletion, said out loud"},
|
||||||
|
{"kettle api repos/{owner}/{repo}/milestones | jq '.[].title'", "the bytes are the server's; jq is yours"},
|
||||||
|
},
|
||||||
|
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||||
|
var method string
|
||||||
|
fs.StringVar(&method, "method", "", "GET, POST, PUT, PATCH or DELETE (default GET, or POST when there is a body)")
|
||||||
|
fs.StringVar(&method, "X", "", "the same flag as --method, spelled the way curl and the tool this replaces spell it")
|
||||||
|
data := fs.String("data", "", "the request body: @file, @- for standard input, or the JSON itself")
|
||||||
|
var fields stringList
|
||||||
|
fs.Var(&fields, "field", "key=value, added to a JSON body as a string; repeatable")
|
||||||
|
status := fs.Bool("status", false, "print the status line on standard error")
|
||||||
|
yes := fs.Bool("yes", false, "confirm a DELETE")
|
||||||
|
|
||||||
|
return func(args []string) error {
|
||||||
|
if len(args) != 1 {
|
||||||
|
return Fail("give exactly one endpoint, e.g. `kettle api repos/{owner}/{repo}/releases`")
|
||||||
|
}
|
||||||
|
if *data != "" && len(fields) > 0 {
|
||||||
|
return Fail("--data and --field are two ways of writing one body — use one of them")
|
||||||
|
}
|
||||||
|
body, err := apiBody(*data, fields)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
verb := strings.ToUpper(method)
|
||||||
|
switch {
|
||||||
|
case verb == "" && body != nil:
|
||||||
|
verb = http.MethodPost
|
||||||
|
case verb == "":
|
||||||
|
verb = http.MethodGet
|
||||||
|
case !apiMethods[verb]:
|
||||||
|
return Fail("%s is not a method this sends — GET, POST, PUT, PATCH or DELETE", verb)
|
||||||
|
}
|
||||||
|
if verb == http.MethodDelete && !*yes {
|
||||||
|
return Fail("-X DELETE deletes something on the tracker — re-run with --yes if that is what you mean")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The store is resolved and then dropped, exactly as `labels`
|
||||||
|
// does: this command touches no issue, but it must fail the same
|
||||||
|
// way as every other tracker command when there is no project,
|
||||||
|
// naming `kettle init` rather than a connection.
|
||||||
|
_, client, err := syncStart("")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
code, answer, err := client.Do(verb, apiEndpoint(args[0], client.Repo()), body, "")
|
||||||
|
if *status && code != 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "%d %s\n", code, http.StatusText(code))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stdout.Write(answer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A newline only when the server did not send one: what came
|
||||||
|
// back is what goes out, and a terminal prompt half way along a
|
||||||
|
// line of JSON is nobody's idea of raw fidelity.
|
||||||
|
if n := len(answer); n > 0 && answer[n-1] != '\n' {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiEndpoint fills the two placeholders in.
|
||||||
|
//
|
||||||
|
// Two and no more: the owner and the name are what a project pins, and every
|
||||||
|
// other id in a Gitea path — an issue number, a release id, a comment id — is
|
||||||
|
// the caller's to know. A path that spells another repository out in full is
|
||||||
|
// left alone, which is how one project reaches another's releases without a
|
||||||
|
// flag.
|
||||||
|
func apiEndpoint(spelled string, repo wire.Repo) string {
|
||||||
|
return strings.NewReplacer("{owner}", repo.Owner, "{repo}", repo.Name).Replace(spelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiBody is the request body, from whichever of the two flags supplied it.
|
||||||
|
//
|
||||||
|
// A nil body is a request with no body at all, which is what a GET and a DELETE
|
||||||
|
// want — as distinct from `--data '{}'`, which is an empty object and a
|
||||||
|
// different thing to send.
|
||||||
|
func apiBody(data string, fields stringList) ([]byte, error) {
|
||||||
|
if len(fields) > 0 {
|
||||||
|
out := make(map[string]string, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
key, value, ok := strings.Cut(f, "=")
|
||||||
|
if !ok || key == "" {
|
||||||
|
return nil, Fail("--field %q is not key=value", f)
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
// Every value is a STRING. Guessing at types is how a tag_name of 1.0
|
||||||
|
// goes up as the number 1 — and a body that needs a boolean, a number or
|
||||||
|
// nesting is a body worth writing down, which is what --data is for.
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
if data == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := []byte(data)
|
||||||
|
switch {
|
||||||
|
case data == "@-":
|
||||||
|
read, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw = read
|
||||||
|
case strings.HasPrefix(data, "@"):
|
||||||
|
read, err := os.ReadFile(data[1:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw = read
|
||||||
|
}
|
||||||
|
// Checked here rather than left to the server, because the answer from
|
||||||
|
// there is a 400 with a parser's opinion in it, and the file that produced
|
||||||
|
// it is not named anywhere in that.
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
if strings.HasPrefix(data, "@") {
|
||||||
|
return nil, Fail("%s does not hold JSON — every body this sends is JSON", data[1:])
|
||||||
|
}
|
||||||
|
return nil, Fail("--data is not JSON — pass @file, @- for standard input, or valid JSON")
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package cmd_test
|
||||||
|
|
||||||
|
// `kettle api` end to end: the real binary, in a throwaway project, against a
|
||||||
|
// fake that records what it was asked for and answers with bytes.
|
||||||
|
//
|
||||||
|
// What is worth proving here is not that HTTP works — internal/gitea has that
|
||||||
|
// against httptest — but the four things this command decides on its own: which
|
||||||
|
// verb goes out, what the endpoint resolves to, that the answer reaches stdout
|
||||||
|
// unchanged, and that a deletion does not happen because a model typed it.
|
||||||
|
//
|
||||||
|
// Every helper is named `ap…` so it cannot collide with the two fakes already in
|
||||||
|
// this package. The version handshake is pullVersionRoute's, because a fake that
|
||||||
|
// does not answer it is a fake no command can build a client against.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apCall is one request as the fake saw it.
|
||||||
|
type apCall struct {
|
||||||
|
Method string
|
||||||
|
URI string
|
||||||
|
Body string
|
||||||
|
Auth string
|
||||||
|
}
|
||||||
|
|
||||||
|
// apTracker answers everything with the same little JSON object and remembers
|
||||||
|
// what it was asked. A status can be armed for the one test that wants a
|
||||||
|
// refusal.
|
||||||
|
type apTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []apCall
|
||||||
|
status int
|
||||||
|
answer string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *apTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if pullVersionRoute(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
|
||||||
|
tr.mu.Lock()
|
||||||
|
tr.calls = append(tr.calls, apCall{
|
||||||
|
Method: r.Method,
|
||||||
|
URI: r.URL.RequestURI(),
|
||||||
|
Body: string(raw),
|
||||||
|
Auth: r.Header.Get("Authorization"),
|
||||||
|
})
|
||||||
|
status, answer := tr.status, tr.answer
|
||||||
|
tr.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
if answer == "" {
|
||||||
|
answer = `{"tag_name":"v0.2.0"}`
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
io.WriteString(w, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *apTracker) apCalls() []apCall {
|
||||||
|
tr.mu.Lock()
|
||||||
|
defer tr.mu.Unlock()
|
||||||
|
return append([]apCall{}, tr.calls...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apEnv starts the fake and returns the environment that points the binary at
|
||||||
|
// it — the same shape a CI run uses, and a credential home that is a temp
|
||||||
|
// directory so no fixture can read the developer's own tokens.
|
||||||
|
func apEnv(t *testing.T, tr *apTracker) []string {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(tr)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return []string{
|
||||||
|
config.EnvURL + "=" + srv.URL,
|
||||||
|
config.EnvToken + "=t0ken",
|
||||||
|
config.EnvRepo + "=owner/repo",
|
||||||
|
config.EnvHome + "=" + t.TempDir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read: GET by default, the placeholders filled from the project, and the
|
||||||
|
// server's bytes on stdout with nothing done to them.
|
||||||
|
func TestAPIGetsAndPrintsWhatCameBack(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
tr := &apTracker{answer: `{"tag_name":"v0.2.0","draft":false}`}
|
||||||
|
|
||||||
|
r := runWith(t, dir, apEnv(t, tr), "", "api", "repos/{owner}/{repo}/releases?limit=50")
|
||||||
|
if r.code != 0 {
|
||||||
|
t.Fatalf("exit %d:\n%s", r.code, r.out())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(r.stdout) != `{"tag_name":"v0.2.0","draft":false}` {
|
||||||
|
t.Errorf("stdout is not the server's bytes:\n%q", r.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := tr.apCalls()
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("%d request(s) went out, want 1 — one invocation is one request: %v", len(calls), calls)
|
||||||
|
}
|
||||||
|
if calls[0].Method != http.MethodGet {
|
||||||
|
t.Errorf("method was %s, want GET", calls[0].Method)
|
||||||
|
}
|
||||||
|
if calls[0].URI != "/api/v1/repos/owner/repo/releases?limit=50" {
|
||||||
|
t.Errorf("endpoint resolved to %s", calls[0].URI)
|
||||||
|
}
|
||||||
|
if calls[0].Auth != "token t0ken" {
|
||||||
|
t.Errorf("Authorization was %q — Gitea's scheme is the word token", calls[0].Auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body from a file: POST is implied by having one, the bytes arrive as they
|
||||||
|
// were written, and the transport files a copy in the project's scratchpad.
|
||||||
|
func TestAPIPostsTheFileItWasGivenAndFilesIt(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
tr := &apTracker{}
|
||||||
|
|
||||||
|
body := `{"tag_name":"v0.2.0","body":"## Changes\n\nwith ` + "`code`" + ` in it"}`
|
||||||
|
path := filepath.Join(dir, "release.json")
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := runWith(t, dir, apEnv(t, tr), "", "api", "--data", "@"+path, "repos/{owner}/{repo}/releases")
|
||||||
|
if r.code != 0 {
|
||||||
|
t.Fatalf("exit %d:\n%s", r.code, r.out())
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := tr.apCalls()
|
||||||
|
if len(calls) != 1 || calls[0].Method != http.MethodPost {
|
||||||
|
t.Fatalf("want one POST, got %v", calls)
|
||||||
|
}
|
||||||
|
if calls[0].Body != body {
|
||||||
|
t.Errorf("the server got\n%s\nwant\n%s", calls[0].Body, body)
|
||||||
|
}
|
||||||
|
// The scratchpad is the transport's, and it holds what went out whether or
|
||||||
|
// not the caller named the file.
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, ".kettle", "payload"))
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("the request body was not filed under .kettle/payload/ (%v, %v)", entries, err)
|
||||||
|
}
|
||||||
|
filed, err := os.ReadFile(filepath.Join(dir, ".kettle", "payload", entries[0].Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(filed), "v0.2.0") {
|
||||||
|
t.Errorf("the filed body is not the one that was sent:\n%s", filed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --field is the small-body form. Every value is a string, and the object it
|
||||||
|
// builds is what goes on the wire.
|
||||||
|
func TestAPIFieldsBuildAJSONObject(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
tr := &apTracker{}
|
||||||
|
|
||||||
|
r := runWith(t, dir, apEnv(t, tr), "", "api",
|
||||||
|
"--field", "title=Wire sqlc", "--field", "head=feat/x", "repos/{owner}/{repo}/pulls")
|
||||||
|
if r.code != 0 {
|
||||||
|
t.Fatalf("exit %d:\n%s", r.code, r.out())
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(tr.apCalls()[0].Body), &got); err != nil {
|
||||||
|
t.Fatalf("the body is not JSON: %v (%s)", err, tr.apCalls()[0].Body)
|
||||||
|
}
|
||||||
|
if got["title"] != "Wire sqlc" || got["head"] != "feat/x" {
|
||||||
|
t.Errorf("the fields did not arrive: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path that spells another repository out in full is left alone: that is how
|
||||||
|
// one project reaches another's releases, and why there is no --repo flag.
|
||||||
|
func TestAPILeavesAFullyNamedRepositoryAlone(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
tr := &apTracker{}
|
||||||
|
|
||||||
|
mustRunWith(t, dir, apEnv(t, tr), "api", "repos/other-owner/other-repo/releases")
|
||||||
|
if got := tr.apCalls()[0].URI; got != "/api/v1/repos/other-owner/other-repo/releases" {
|
||||||
|
t.Errorf("the project's own repository was substituted into a path that named one: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside a project there is nothing to run against, and the failure says which
|
||||||
|
// command makes one — never a connection error, and never a guess at a tracker.
|
||||||
|
func TestAPIOutsideAProjectNamesInit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tr := &apTracker{}
|
||||||
|
|
||||||
|
r := runWith(t, dir, apEnv(t, tr), "", "api", "user")
|
||||||
|
if r.code != 1 {
|
||||||
|
t.Fatalf("exit %d, want 1:\n%s", r.code, r.out())
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.stderr, "no .kettle/ found") {
|
||||||
|
t.Errorf("the failure does not name what was searched:\n%s", r.stderr)
|
||||||
|
}
|
||||||
|
if len(tr.apCalls()) != 0 {
|
||||||
|
t.Error("a request went out from a directory that is not a project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal is an exit 1 that quotes the status and what the server said —
|
||||||
|
// which is the only thing that tells four different 422s apart.
|
||||||
|
func TestAPIReportsTheStatusAndTheBodyOnAFailure(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
tr := &apTracker{status: http.StatusNotFound, answer: `{"message":"release does not exist"}`}
|
||||||
|
|
||||||
|
r := runWith(t, dir, apEnv(t, tr), "", "api", "--status", "repos/{owner}/{repo}/releases/9")
|
||||||
|
if r.code != 1 {
|
||||||
|
t.Fatalf("exit %d, want 1:\n%s", r.code, r.out())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"404", "release does not exist"} {
|
||||||
|
if !strings.Contains(r.stderr, want) {
|
||||||
|
t.Errorf("stderr does not mention %q:\n%s", want, r.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(r.stdout, "release does not exist") {
|
||||||
|
t.Errorf("a failed body was printed as though it were an answer:\n%s", r.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deletion is an operator's decision. Without --yes nothing is sent at all —
|
||||||
|
// the refusal comes before the request, not after it.
|
||||||
|
func TestAPIDeleteNeedsYes(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
tr := &apTracker{}
|
||||||
|
env := apEnv(t, tr)
|
||||||
|
|
||||||
|
r := runWith(t, dir, env, "", "api", "-X", "DELETE", "repos/{owner}/{repo}/releases/12")
|
||||||
|
if r.code != 1 || !strings.Contains(r.stderr, "--yes") {
|
||||||
|
t.Fatalf("a DELETE without --yes must be refused by name:\n%s", r.out())
|
||||||
|
}
|
||||||
|
if len(tr.apCalls()) != 0 {
|
||||||
|
t.Fatal("the request went out anyway — the gate is before the socket, or it is not a gate")
|
||||||
|
}
|
||||||
|
|
||||||
|
mustRunWith(t, dir, env, "api", "-X", "DELETE", "--yes", "repos/{owner}/{repo}/releases/12")
|
||||||
|
calls := tr.apCalls()
|
||||||
|
if len(calls) != 1 || calls[0].Method != http.MethodDelete {
|
||||||
|
t.Errorf("--yes did not let the deletion through: %v", calls)
|
||||||
|
}
|
||||||
|
if calls[0].Body != "" {
|
||||||
|
t.Errorf("a DELETE carried a body: %q", calls[0].Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A method this does not send is refused before anything is resolved: an
|
||||||
|
// unquoted endpoint that lost a word to the shell must not go out as a verb.
|
||||||
|
func TestAPIRefusesAMethodItDoesNotSend(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
tr := &apTracker{}
|
||||||
|
|
||||||
|
r := runWith(t, dir, apEnv(t, tr), "", "api", "-X", "HEAD", "user")
|
||||||
|
if r.code != 1 || !strings.Contains(r.stderr, "GET, POST, PUT, PATCH or DELETE") {
|
||||||
|
t.Errorf("an unsupported method was not named:\n%s", r.out())
|
||||||
|
}
|
||||||
|
if len(tr.apCalls()) != 0 {
|
||||||
|
t.Error("a request went out for a method this does not send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRunWith is mustRun with an environment.
|
||||||
|
func mustRunWith(t *testing.T, dir string, env []string, args ...string) result {
|
||||||
|
t.Helper()
|
||||||
|
r := runWith(t, dir, env, "", args...)
|
||||||
|
if r.code != 0 {
|
||||||
|
t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out())
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -27,16 +27,27 @@ argument is in the shell history the moment it is typed:
|
|||||||
kettle auth add --name noodles --url https://git.example.com < token.txt
|
kettle auth add --name noodles --url https://git.example.com < token.txt
|
||||||
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
||||||
|
|
||||||
` + "`list`" + ` never prints a token. There is no flag to make it.`,
|
` + "`list`" + ` never prints a token. There is no flag to make it.
|
||||||
|
|
||||||
|
--scopes RECORDS WHAT THE TOKEN WAS MINTED WITH, and records is all it does:
|
||||||
|
nothing is checked against it and nothing is refused because of it. It is worth
|
||||||
|
writing down because the instance will not answer the question — Gitea's own
|
||||||
|
token listing needs a password, not a token, so a token cannot be asked what it
|
||||||
|
may do. Gitea spells them <read|write>:<category>; issues need ` + "`write:issue`" + `,
|
||||||
|
and everything ` + "`kettle api`" + ` reaches outside issues — releases, pull requests,
|
||||||
|
branches, tags, actions — is ` + "`repository`" + `. A token minted for issues alone
|
||||||
|
answers 403 there, and the 403 names no scope.`,
|
||||||
Examples: []Example{
|
Examples: []Example{
|
||||||
{"kettle auth list", "what this machine holds"},
|
{"kettle auth list", "what this machine holds"},
|
||||||
{"pass show gitea | kettle auth add --name noodles --url https://git.example.com", "add one, token on stdin"},
|
{"pass show gitea | kettle auth add --name noodles --url https://git.example.com", "add one, token on stdin"},
|
||||||
|
{"kettle auth add --name noodles --url https://git.example.com --scopes write:issue,write:repository < t.txt", "and write down what it can do"},
|
||||||
{"kettle auth remove noodles", "forget it"},
|
{"kettle auth remove noodles", "forget it"},
|
||||||
},
|
},
|
||||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||||
name := fs.String("name", "", "login name (add)")
|
name := fs.String("name", "", "login name (add)")
|
||||||
url := fs.String("url", "", "instance URL, e.g. https://git.example.com (add)")
|
url := fs.String("url", "", "instance URL, e.g. https://git.example.com (add)")
|
||||||
user := fs.String("user", "", "account this token belongs to; documentation only (add)")
|
user := fs.String("user", "", "account this token belongs to; documentation only (add)")
|
||||||
|
scopes := fs.String("scopes", "", "what the token was minted with, comma separated, e.g. write:issue,write:repository; documentation only (add)")
|
||||||
token := fs.String("token", "", "token, if you would rather not use stdin (add)")
|
token := fs.String("token", "", "token, if you would rather not use stdin (add)")
|
||||||
|
|
||||||
return func(args []string) error {
|
return func(args []string) error {
|
||||||
@@ -61,7 +72,14 @@ argument is in the shell history the moment it is typed:
|
|||||||
if who == "" {
|
if who == "" {
|
||||||
who = "—"
|
who = "—"
|
||||||
}
|
}
|
||||||
fmt.Printf(" %-16s %-40s %s\n", l.Name, l.URL, who)
|
// Not recorded is not the same as none, and a listing
|
||||||
|
// that printed "—" for both would be the reason somebody
|
||||||
|
// re-mints a token that was fine.
|
||||||
|
scopes := "(not recorded)"
|
||||||
|
if len(l.Scopes) > 0 {
|
||||||
|
scopes = strings.Join(l.Scopes, ", ")
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-16s %-40s %-16s %s\n", l.Name, l.URL, who, scopes)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
@@ -79,10 +97,11 @@ argument is in the shell history the moment it is typed:
|
|||||||
return Fail("no token — pipe one in, or pass --token")
|
return Fail("no token — pipe one in, or pass --token")
|
||||||
}
|
}
|
||||||
entry := config.Login{
|
entry := config.Login{
|
||||||
Name: *name,
|
Name: *name,
|
||||||
URL: strings.TrimRight(*url, "/"),
|
URL: strings.TrimRight(*url, "/"),
|
||||||
User: *user,
|
User: *user,
|
||||||
Token: secret,
|
Scopes: splitScopes(*scopes),
|
||||||
|
Token: secret,
|
||||||
}
|
}
|
||||||
if existing := logins.Find(*name); existing != nil {
|
if existing := logins.Find(*name); existing != nil {
|
||||||
*existing = entry
|
*existing = entry
|
||||||
@@ -125,6 +144,21 @@ argument is in the shell history the moment it is typed:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitScopes reads the comma-separated list --scopes takes.
|
||||||
|
//
|
||||||
|
// Nothing here validates a scope name against Gitea's set: the set is the
|
||||||
|
// server's and it grows, and a spelling this binary has not heard of is more
|
||||||
|
// likely a newer Gitea than a typo. The field is a note to a human either way.
|
||||||
|
func splitScopes(v string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range strings.Split(v, ",") {
|
||||||
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// readToken takes the first non-empty line of r, trimmed.
|
// readToken takes the first non-empty line of r, trimmed.
|
||||||
//
|
//
|
||||||
// The first line, not the whole stream: a token piped from a password manager
|
// The first line, not the whole stream: a token piped from a password manager
|
||||||
|
|||||||
@@ -474,6 +474,40 @@ func TestAuthListNeverPrintsATokenAndRemoveForgetsIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a token was minted with is written down because the instance will not
|
||||||
|
// say: Gitea's own token listing needs a password, not a token. It is
|
||||||
|
// documentation — nothing is checked against it — and the one thing it must not
|
||||||
|
// do is read as "none" when nobody wrote it down.
|
||||||
|
func TestScopesAreRecordedAndShownButNeverInvented(t *testing.T) {
|
||||||
|
dir := newProject(t)
|
||||||
|
home := t.TempDir()
|
||||||
|
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||||
|
|
||||||
|
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles",
|
||||||
|
"--url", "https://git.example.com", "--scopes", "write:issue, write:repository")
|
||||||
|
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
|
||||||
|
|
||||||
|
listed := runWith(t, dir, env, "", "auth", "list")
|
||||||
|
if !strings.Contains(listed.stdout, "write:issue, write:repository") {
|
||||||
|
t.Errorf("`auth list` does not show what was recorded:\n%s", listed.out())
|
||||||
|
}
|
||||||
|
if strings.Contains(listed.out(), "s3cr3t-token") {
|
||||||
|
t.Errorf("`auth list` printed a token:\n%s", listed.out())
|
||||||
|
}
|
||||||
|
shown := runWith(t, dir, env, "", "config")
|
||||||
|
if !strings.Contains(shown.stdout, "scopes write:issue, write:repository") {
|
||||||
|
t.Errorf("`config` does not show the scopes beside the token they belong to:\n%s", shown.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A login nobody recorded scopes for says so in those words. "—" would read
|
||||||
|
// as "no scopes", which is the sentence that gets a working token re-minted.
|
||||||
|
runWith(t, dir, env, "other-token\n", "auth", "add", "--name", "bare", "--url", "https://git.example.com")
|
||||||
|
bare := runWith(t, dir, env, "", "auth", "list")
|
||||||
|
if !strings.Contains(bare.stdout, "(not recorded)") {
|
||||||
|
t.Errorf("a login with no scopes written down must say so:\n%s", bare.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A pinned login that is not on this machine is a fixable mistake, and the
|
// A pinned login that is not on this machine is a fixable mistake, and the
|
||||||
// message has to say which file was read and what it holds.
|
// message has to say which file was read and what it holds.
|
||||||
func TestAMissingLoginIsExplained(t *testing.T) {
|
func TestAMissingLoginIsExplained(t *testing.T) {
|
||||||
|
|||||||
@@ -26,14 +26,16 @@ const (
|
|||||||
GroupProject = "project"
|
GroupProject = "project"
|
||||||
GroupIssue = "issue"
|
GroupIssue = "issue"
|
||||||
GroupSync = "sync"
|
GroupSync = "sync"
|
||||||
|
GroupAPI = "api"
|
||||||
)
|
)
|
||||||
|
|
||||||
var groupOrder = []string{GroupProject, GroupIssue, GroupSync}
|
var groupOrder = []string{GroupProject, GroupIssue, GroupSync, GroupAPI}
|
||||||
|
|
||||||
var groupBlurb = map[string]string{
|
var groupBlurb = map[string]string{
|
||||||
GroupProject: "the project itself",
|
GroupProject: "the project itself",
|
||||||
GroupIssue: "issues as units of work — offline, no tracker involved",
|
GroupIssue: "issues as units of work — offline, no tracker involved",
|
||||||
GroupSync: "moving issues between the store and the tracker",
|
GroupSync: "moving issues between the store and the tracker",
|
||||||
|
GroupAPI: "everything else Gitea has, reached directly — not issues",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example is one worked invocation. Both halves are shown in help and in the
|
// Example is one worked invocation. Both halves are shown in help and in the
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
@@ -47,6 +48,14 @@ This is the command to reach for when the store looks empty, when a push says
|
|||||||
fmt.Printf("login %s\n", orNone(red.Login))
|
fmt.Printf("login %s\n", orNone(red.Login))
|
||||||
fmt.Printf("url %s\n", orNone(red.URL))
|
fmt.Printf("url %s\n", orNone(red.URL))
|
||||||
fmt.Printf("token %s\n", orNone(red.Token))
|
fmt.Printf("token %s\n", orNone(red.Token))
|
||||||
|
// What the login says its token can do, which is a note somebody
|
||||||
|
// wrote and not an answer from the instance — a 403 out of
|
||||||
|
// `kettle api` is read against this line.
|
||||||
|
scopes := "(not recorded)"
|
||||||
|
if len(red.Scopes) > 0 {
|
||||||
|
scopes = strings.Join(red.Scopes, ", ")
|
||||||
|
}
|
||||||
|
fmt.Printf("scopes %s\n", scopes)
|
||||||
if r.Owner != "" {
|
if r.Owner != "" {
|
||||||
fmt.Printf("repo %s\n", r.Slug())
|
fmt.Printf("repo %s\n", r.Slug())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,9 +13,19 @@ the tree that imports yaml.
|
|||||||
<project>/.kettle/config.yaml login: noodles a NAME, never a token
|
<project>/.kettle/config.yaml login: noodles a NAME, never a token
|
||||||
repo: owner/name
|
repo: owner/name
|
||||||
|
|
||||||
~/.config/kettle/logins.yaml logins: [{name, url, user, token}] mode 0600
|
~/.config/kettle/logins.yaml logins: [{name, url, user, scopes, token}]
|
||||||
|
mode 0600
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`user` and `scopes` are **documentation and nothing else** — nothing is checked
|
||||||
|
against either, and no request is refused because of one. `scopes` is what the
|
||||||
|
token was minted with, as Gitea spells it (`write:issue`, `write:repository`),
|
||||||
|
and it is written down because the instance will not answer the question:
|
||||||
|
`GET /user/tokens` needs basic auth rather than token auth, so a token cannot be
|
||||||
|
asked what it may do. What it buys is a 403 that can be read — `kettle auth list`
|
||||||
|
and `kettle config` show what was recorded, and an empty list means "nobody wrote
|
||||||
|
it down", never "none".
|
||||||
|
|
||||||
**A token in a file inside a working tree ends up in a commit.** Not always, not
|
**A token in a file inside a working tree ends up in a commit.** Not always, not
|
||||||
immediately, and not by anyone careless — but a project config is exactly the file
|
immediately, and not by anyone careless — but a project config is exactly the file
|
||||||
somebody eventually decides to share, and a secret that has ever been pushed has to
|
somebody eventually decides to share, and a secret that has ever been pushed has to
|
||||||
@@ -84,6 +94,15 @@ Not a silent drop. An older binary reading a newer config would otherwise delete
|
|||||||
setting it did not recognize the next time it wrote the file — which is a data-loss
|
setting it did not recognize the next time it wrote the file — which is a data-loss
|
||||||
bug that only shows up on the machine running the older build.
|
bug that only shows up on the machine running the older build.
|
||||||
|
|
||||||
|
**The price is that a field added here is a one-way door for the file that holds
|
||||||
|
it.** `scopes:` was the first one to prove it: a login file written by a binary
|
||||||
|
that has the field cannot be read by one that does not — the older build stops
|
||||||
|
with "unknown field" rather than dropping the line. That is acceptable for
|
||||||
|
`logins.yaml`, which is machine-local and whose reader is the one binary the
|
||||||
|
operator upgrades; it would **not** be acceptable for `config.yaml`, which is
|
||||||
|
committed and read by whatever version each machine happens to have. Adding a
|
||||||
|
field to the project file means answering that first, out loud, here.
|
||||||
|
|
||||||
## What does not belong here
|
## What does not belong here
|
||||||
|
|
||||||
A request, a store path, an issue. This package reads and writes two files and
|
A request, a store path, an issue. This package reads and writes two files and
|
||||||
|
|||||||
@@ -57,10 +57,18 @@ type Project struct {
|
|||||||
|
|
||||||
// Login is one set of credentials for one Gitea instance.
|
// Login is one set of credentials for one Gitea instance.
|
||||||
type Login struct {
|
type Login struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
User string `yaml:"user,omitempty"`
|
User string `yaml:"user,omitempty"`
|
||||||
Token string `yaml:"token"`
|
// Scopes is what the token was minted with, as Gitea spells it —
|
||||||
|
// `write:issue`, `read:repository`. DOCUMENTATION ONLY, exactly like User:
|
||||||
|
// nothing is checked against it and nothing is refused because of it. It is
|
||||||
|
// written down because the instance will not say. `GET /user/tokens` needs
|
||||||
|
// basic auth, not token auth, so a token cannot be asked what it may do —
|
||||||
|
// and the failure that costs an afternoon is a 403 on a release from a token
|
||||||
|
// somebody minted for issues a year ago.
|
||||||
|
Scopes []string `yaml:"scopes,omitempty"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logins is the machine-wide file.
|
// Logins is the machine-wide file.
|
||||||
@@ -216,6 +224,11 @@ type Resolved struct {
|
|||||||
Token string
|
Token string
|
||||||
Owner string
|
Owner string
|
||||||
Repo string
|
Repo string
|
||||||
|
// Scopes is what the pinned login records its token was minted with.
|
||||||
|
// Documentation, carried this far so `kettle config` can show it beside the
|
||||||
|
// token it belongs to; nothing dials on it. A token out of the environment
|
||||||
|
// records nothing, and an empty list means "not written down", never "none".
|
||||||
|
Scopes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slug is owner/name, the way a tracker writes it.
|
// Slug is owner/name, the way a tracker writes it.
|
||||||
@@ -302,7 +315,7 @@ func merge(p Project) (*Resolved, error) {
|
|||||||
return nil, fmt.Errorf("no login %q in %s — known: %s; add one with `kettle auth add`",
|
return nil, fmt.Errorf("no login %q in %s — known: %s; add one with `kettle auth add`",
|
||||||
out.Login, LoginsPath(), known)
|
out.Login, LoginsPath(), known)
|
||||||
}
|
}
|
||||||
out.URL, out.Token = entry.URL, entry.Token
|
out.URL, out.Token, out.Scopes = entry.URL, entry.Token, entry.Scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
if v := os.Getenv(EnvURL); v != "" {
|
if v := os.Getenv(EnvURL); v != "" {
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ package, not beside it. `TestTransportDoesNotImportTheDomain` is the check.
|
|||||||
|
|
||||||
| file | what is in it |
|
| file | what is in it |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `client.go` | `New`, `For`, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail`, `ListIssues` and its budget |
|
| `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, dependencies |
|
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, `ListIssues` and its budget, dependencies |
|
||||||
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
|
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
|
||||||
| `remotemap.go` | `RemoteMap` — the number → slug ledger, and why nothing prunes it |
|
| `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 |
|
| `remotemap_test.go` | load, merge, save |
|
||||||
|
|
||||||
## What this package is, now that the SDK exists
|
## 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",
|
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".
|
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
|
## The ledger
|
||||||
|
|
||||||
`.remote.json`, **inside the issue store, beside the issues it indexes** — it is
|
`.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
|
- **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
|
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.
|
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
|
So it goes out through `Do` — same HTTP client, same payload dump, same
|
||||||
else — same payload dump, same `*APIError`. The URL names the blocked issue and
|
`*APIError`. This was the request `Do` was written for, back when it was the only
|
||||||
the body the blocker, which is the direction `Dependencies` reads back.
|
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
|
- **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
|
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
|
answered from the version it already gave us rather than from a 404 — which on an
|
||||||
|
|||||||
+116
-22
@@ -69,8 +69,9 @@ const (
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
// api is the SDK client: one per run, shared by every copy For makes.
|
// api is the SDK client: one per run, shared by every copy For makes.
|
||||||
api *sdk.Client
|
api *sdk.Client
|
||||||
// http is the SDK's transport, kept because AddDependency still sends one
|
// http is the SDK's transport, kept because Do sends requests by hand — the
|
||||||
// request by hand — see there.
|
// dependency endpoint the SDK cannot spell, and every endpoint this package
|
||||||
|
// has no method for.
|
||||||
http *http.Client
|
http *http.Client
|
||||||
// dump is the RoundTripper that files request bodies. Shared with every
|
// dump is the RoundTripper that files request bodies. Shared with every
|
||||||
// copy For makes, because the scratchpad is one directory per run.
|
// copy For makes, because the scratchpad is one directory per run.
|
||||||
@@ -186,9 +187,31 @@ func (e *APIError) Error() string {
|
|||||||
if status != "" {
|
if status != "" {
|
||||||
status = " " + status
|
status = " " + status
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
out := fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
||||||
|
if e.Status == http.StatusForbidden {
|
||||||
|
out += "\n" + scopeHint
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scopeHint is what a 403 gets said after it.
|
||||||
|
//
|
||||||
|
// Gitea scopes a token as <read|write>:<category>, and a token minted to file
|
||||||
|
// issues carries `write:issue` and nothing more — which is exactly right until
|
||||||
|
// the first request outside issues, where releases, pull requests, branches and
|
||||||
|
// tags all live under `repository` and the answer is a 403 that names no scope
|
||||||
|
// at all. The server will not say which one is missing, so this does not guess
|
||||||
|
// one; it names the two commands that show what was recorded and let it be
|
||||||
|
// re-recorded.
|
||||||
|
//
|
||||||
|
// Blanket rather than per-call, because the transport does not know which
|
||||||
|
// category an arbitrary endpoint belongs to — and a 403 on a request that had
|
||||||
|
// the scope is a permissions problem on the repository, which this sentence does
|
||||||
|
// not contradict.
|
||||||
|
const scopeHint = "a 403 is usually the token's scopes rather than the request: Gitea scopes a token as " +
|
||||||
|
"<read|write>:<category>, and everything outside issues (releases, pull requests, branches, tags, actions) " +
|
||||||
|
"is `repository`. `kettle auth list` shows what each login on this machine records."
|
||||||
|
|
||||||
// StatusIs reports whether err is an API answer with this status code, for the
|
// StatusIs reports whether err is an API answer with this status code, for the
|
||||||
// handful of places where one code means something specific — a 409 from a
|
// handful of places where one code means something specific — a 409 from a
|
||||||
// dependency link that is already there, say.
|
// dependency link that is already there, say.
|
||||||
@@ -404,30 +427,58 @@ func safeName(name string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// the one request the SDK cannot express
|
// the requests the SDK cannot express
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
// post sends one JSON body to a path under this instance's API and ignores
|
// Do sends one request to a path under this instance's API and returns the
|
||||||
// whatever comes back.
|
// status and the body exactly as they came back.
|
||||||
//
|
//
|
||||||
// It exists for AddDependency and for nothing else — see there for what the SDK
|
// It is the escape hatch, and it was here before it was one: the dependency
|
||||||
// leaves out. It goes through the same http.Client, so the body is filed and a
|
// endpoint needed a body the SDK's own type cannot spell (see AddDependency),
|
||||||
// failure carries the status and the server's words exactly as every other call
|
// so a hand-rolled request already existed. What has changed is that it is
|
||||||
// in this package does.
|
// exported, which is what lets `kettle api` reach a release, a pull request or a
|
||||||
func (c *Client) post(path string, body any, name string) error {
|
// webhook without this package growing a method per entity — and without a
|
||||||
raw, err := json.Marshal(body)
|
// second client that would hold the credentials all over again.
|
||||||
|
//
|
||||||
|
// It goes through the same http.Client as everything else, which is the whole
|
||||||
|
// point: the same dump-RoundTripper files the body under `.kettle/payload/`, the
|
||||||
|
// same `token …` header authenticates it, and a non-2xx comes back as the same
|
||||||
|
// *APIError carrying the status AND what the server said.
|
||||||
|
//
|
||||||
|
// THREE THINGS IT DELIBERATELY DOES NOT DO:
|
||||||
|
//
|
||||||
|
// - IT DOES NOT PAGINATE. One call is one HTTP request. The pagination in this
|
||||||
|
// package exists for a listing with a budget to spend, and a passthrough that
|
||||||
|
// quietly stitched pages together would report as one answer something that
|
||||||
|
// was several — `?page=` and `?limit=` are the caller's to spell.
|
||||||
|
// - IT DOES NOT PARSE OR REFORMAT THE ANSWER. Bytes in, bytes out. Whoever
|
||||||
|
// asked knows what they asked for; re-indenting it here would only be a
|
||||||
|
// second opinion about somebody else's JSON.
|
||||||
|
// - IT DOES NOT KNOW WHAT AN ISSUE IS. Nothing about it names a domain concept,
|
||||||
|
// so the layering rule holds unchanged — this is still transport, and a
|
||||||
|
// generic one is no more a domain than a specific one was.
|
||||||
|
//
|
||||||
|
// A nil body sends no body at all, which is what a GET and a DELETE want; the
|
||||||
|
// Content-Type goes on only when there is something to type.
|
||||||
|
func (c *Client) Do(method, path string, body []byte, name string) (int, []byte, error) {
|
||||||
|
endpoint, err := c.endpoint(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
endpoint := c.base + "/api/v1/" + strings.TrimLeft(path, "/")
|
var reader io.Reader
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
|
if body != nil {
|
||||||
|
reader = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, endpoint, reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
|
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
|
||||||
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
|
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
|
||||||
req.Header.Set("Authorization", "token "+c.token)
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
@@ -436,14 +487,57 @@ func (c *Client) post(path string, body any, name string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// The token travels in a header and never in the URL, so an error is
|
// The token travels in a header and never in the URL, so an error is
|
||||||
// free to quote the URL in full.
|
// free to quote the URL in full.
|
||||||
return err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
answer, _ := io.ReadAll(resp.Body)
|
answer, err := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
if err != nil {
|
||||||
return &APIError{Method: http.MethodPost, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
return resp.StatusCode, nil, fmt.Errorf("%s %s: %d answered with a body that could not be read: %w",
|
||||||
|
method, endpoint, resp.StatusCode, err)
|
||||||
}
|
}
|
||||||
return nil
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
return resp.StatusCode, answer, &APIError{
|
||||||
|
Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
||||||
|
}
|
||||||
|
return resp.StatusCode, answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpoint resolves what a caller spelled into an absolute URL on this instance.
|
||||||
|
//
|
||||||
|
// The three spellings are the ones `tea api` accepted, so a table of endpoints
|
||||||
|
// written for that tool still works here: a bare path is under `/api/v1/`, a
|
||||||
|
// path already starting `/api/` is taken as it stands (that is how anything
|
||||||
|
// outside v1 is reached), and a full URL is a full URL.
|
||||||
|
//
|
||||||
|
// A FULL URL MUST BE ON THIS INSTANCE, and that is the one place this is
|
||||||
|
// stricter than the tool it replaces. Every request made here carries the
|
||||||
|
// project's token in a header; a URL pointing somewhere else would hand that
|
||||||
|
// token to whatever host was named, which is a credential leak spelled as a
|
||||||
|
// convenience. Reaching another instance is what KETTLE_URL is for.
|
||||||
|
func (c *Client) endpoint(path string) (string, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
|
||||||
|
if path != c.base && !strings.HasPrefix(path, c.base+"/") {
|
||||||
|
return "", fmt.Errorf("%s is not on %s — this token belongs to that instance and is sent nowhere else"+
|
||||||
|
" (point %s at the other one instead)", path, c.base, config.EnvURL)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
case strings.HasPrefix(path, "/api/"):
|
||||||
|
return c.base + path, nil
|
||||||
|
default:
|
||||||
|
return c.base + "/api/v1/" + strings.TrimLeft(path, "/"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// post sends one JSON body to a path under this instance's API and ignores
|
||||||
|
// whatever comes back. AddDependency is what it is for.
|
||||||
|
func (c *Client) post(path string, body any, name string) error {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _, err = c.Do(http.MethodPost, path, raw, name)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -487,6 +487,147 @@ func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The generic request: bytes out, bytes back, and the same three services every
|
||||||
|
// other call in this package gets — the header, the scratchpad, the *APIError.
|
||||||
|
func TestDoAnswersWithWhatTheServerSent(t *testing.T) {
|
||||||
|
root := newProject(t)
|
||||||
|
|
||||||
|
var got struct{ method, uri, auth, ctype string }
|
||||||
|
var sent []byte
|
||||||
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got.method, got.uri = r.Method, r.URL.RequestURI()
|
||||||
|
got.auth, got.ctype = r.Header.Get("Authorization"), r.Header.Get("Content-Type")
|
||||||
|
sent, _ = io.ReadAll(r.Body)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
io.WriteString(w, `{"tag_name":"v0.2.0"}`)
|
||||||
|
})
|
||||||
|
c := newClient(t, srv.URL)
|
||||||
|
|
||||||
|
// A read: no body out, and nothing filed — the scratchpad holds what was
|
||||||
|
// SENT, and a run that sent nothing leaves no directory behind.
|
||||||
|
code, answer, err := c.Do(http.MethodGet, "repos/acme/widgets/releases?limit=50", nil, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
if code != http.StatusCreated || string(answer) != `{"tag_name":"v0.2.0"}` {
|
||||||
|
t.Errorf("got %d %q, want 201 and the server's bytes", code, answer)
|
||||||
|
}
|
||||||
|
if got.uri != "/api/v1/repos/acme/widgets/releases?limit=50" {
|
||||||
|
t.Errorf("the endpoint was rewritten: %s", got.uri)
|
||||||
|
}
|
||||||
|
if got.auth != "token s3cret" {
|
||||||
|
t.Errorf("Authorization was %q, want %q", got.auth, "token s3cret")
|
||||||
|
}
|
||||||
|
if got.ctype != "" {
|
||||||
|
t.Errorf("a request with no body carried Content-Type %q", got.ctype)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("a read created the payload directory (%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write: the body goes out verbatim and is filed under the name it was
|
||||||
|
// given, by the same RoundTripper that files every other request.
|
||||||
|
body := []byte(`{"tag_name":"v0.2.0","body":"a & b"}`)
|
||||||
|
if _, _, err := c.Do(http.MethodPost, "/api/v1/repos/acme/widgets/releases", body, "release-v0-2-0"); err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
if got.method != http.MethodPost || got.ctype != "application/json" {
|
||||||
|
t.Errorf("the write went out as %s %q", got.method, got.ctype)
|
||||||
|
}
|
||||||
|
if string(sent) != string(body) {
|
||||||
|
t.Errorf("the server got %s, want %s — a passthrough reformatted the body", sent, body)
|
||||||
|
}
|
||||||
|
filed, err := os.ReadFile(filepath.Join(root, ".kettle", "payload", "release-v0-2-0.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the body was not filed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(filed), `"tag_name": "v0.2.0"`) {
|
||||||
|
t.Errorf("the dump is not the body that was sent:\n%s", filed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal comes back as this package's error, with the status and the
|
||||||
|
// server's own words — and the status and body are returned as well, so a
|
||||||
|
// caller that would rather print them than wrap them can.
|
||||||
|
func TestDoReportsAStatusAndTheServersWords(t *testing.T) {
|
||||||
|
newProject(t)
|
||||||
|
|
||||||
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
io.WriteString(w, `{"message":"release does not exist"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
code, answer, err := newClient(t, srv.URL).Do(http.MethodGet, "repos/acme/widgets/releases/9", nil, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a 404 came back as success")
|
||||||
|
}
|
||||||
|
var apiErr *gitea.APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
||||||
|
}
|
||||||
|
if code != http.StatusNotFound || !strings.Contains(string(answer), "release does not exist") {
|
||||||
|
t.Errorf("got %d %q; the status and the body are the caller's too", code, answer)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"404", "release does not exist", "GET", "/api/v1/repos/acme/widgets/releases/9"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the error does not mention %q:\n%s", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 403 is answered with what to do about it, because Gitea's own 403 names no
|
||||||
|
// scope and a token minted for issues is the usual reason.
|
||||||
|
func TestAForbiddenAnswerNamesTheScopeItMightBe(t *testing.T) {
|
||||||
|
newProject(t)
|
||||||
|
|
||||||
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
io.WriteString(w, `{"message":"token does not have at least one of required scope(s)"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _, err := newClient(t, srv.URL).Do(http.MethodPost, "repos/acme/widgets/releases", []byte(`{}`), "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a 403 came back as success")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"403", "kettle auth list", "repository"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("a 403 does not say %q — the server named no scope, so this has to:\n%s", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is this instance's. A full URL somewhere else is refused before a
|
||||||
|
// socket is opened, because sending it would hand the credential to whatever
|
||||||
|
// host was typed.
|
||||||
|
func TestDoRefusesAURLOnAnotherHost(t *testing.T) {
|
||||||
|
newProject(t)
|
||||||
|
|
||||||
|
asked := 0
|
||||||
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
asked++
|
||||||
|
writeJSON(t, w, map[string]any{})
|
||||||
|
})
|
||||||
|
c := newClient(t, srv.URL)
|
||||||
|
|
||||||
|
_, _, err := c.Do(http.MethodGet, "https://gitea.example.invalid/api/v1/user", nil, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a request to another host was allowed — that sends this project's token to it")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "s3cret") {
|
||||||
|
t.Errorf("the refusal quotes the token:\n%s", err)
|
||||||
|
}
|
||||||
|
if asked != 0 {
|
||||||
|
t.Errorf("%d request(s) went out for an endpoint that was refused", asked)
|
||||||
|
}
|
||||||
|
// A full URL on the instance itself is the same request as the bare path.
|
||||||
|
if _, _, err := c.Do(http.MethodGet, srv.URL+"/api/v1/user", nil, ""); err != nil {
|
||||||
|
t.Errorf("a full URL on this instance was refused: %v", err)
|
||||||
|
}
|
||||||
|
if asked != 1 {
|
||||||
|
t.Errorf("%d request(s) went out, want 1", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A half-filled configuration is refused here rather than at the first 401,
|
// A half-filled configuration is refused here rather than at the first 401,
|
||||||
// because a 401 names nothing an operator can act on — and before the client is
|
// because a 401 names nothing an operator can act on — and before the client is
|
||||||
// built at all, because building one dials.
|
// built at all, because building one dials.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kettle",
|
"name": "kettle",
|
||||||
"description": "Issues as local markdown, driven by the kettle binary: /kettle:init makes a directory a project, /kettle:issue works on issues offline (format, validation, checkboxes, dependency graph), /kettle:sync moves them to and from Gitea, /kettle:auth manages the credential a project runs under, /kettle:use is the tea CLI reference for the Gitea entities kettle does not cover, and the kettle-runner subagent executes batches on a cheap model. The command reference in each skill is generated from the binary's own command registry, so it cannot drift.",
|
"description": "Issues as local markdown, driven by the kettle binary: /kettle:init makes a directory a project, /kettle:issue works on issues offline (format, validation, checkboxes, dependency graph), /kettle:sync moves them to and from Gitea, /kettle:auth manages the credential a project runs under, /kettle:api reaches everything else Gitea has — pull requests, releases, tags, milestones, actions, webhooks — through that same login, and the kettle-runner subagent executes batches on a cheap model. The command reference in each skill is generated from the binary's own command registry, so it cannot drift.",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "naudachu"
|
"name": "naudachu"
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-11
@@ -31,9 +31,9 @@ go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
|||||||
```
|
```
|
||||||
|
|
||||||
`cli/go.mod` says **go 1.26** — the Gitea SDK requires it, so that is the minimum
|
`cli/go.mod` says **go 1.26** — the Gitea SDK requires it, so that is the minimum
|
||||||
for anybody building this. There is no `vendor/`: a build resolves its modules from
|
for anybody building this. `vendor/` is committed, so what compiles is what is in
|
||||||
the module cache or the network, and `go.sum` is what makes that safe. Why that
|
this repository's history; what that does and does not buy is in
|
||||||
trade was taken is in [`cli/AGENTS.md`](../../cli/AGENTS.md).
|
[`cli/AGENTS.md`](../../cli/AGENTS.md).
|
||||||
|
|
||||||
An operator who sees `command not found: kettle` installs it and re-runs; there
|
An operator who sees `command not found: kettle` installs it and re-runs; there
|
||||||
is nothing to configure in this plugin either way. `kettle config` is the command
|
is nothing to configure in this plugin either way. `kettle config` is the command
|
||||||
@@ -63,19 +63,24 @@ the run reports as `without a region` is one where somebody dropped the markers
|
|||||||
it is left alone, never overwritten, and the fix is to put them back.
|
it is left alone, never overwritten, and the fix is to put them back.
|
||||||
|
|
||||||
**Groups and skills are not the same set, and that is the one seam.** The binary
|
**Groups and skills are not the same set, and that is the one seam.** The binary
|
||||||
groups its commands `project`, `issue`, `sync`; the plugin's skills are `init`,
|
groups its commands `project`, `issue`, `sync`, `api`; the plugin's skills are
|
||||||
`auth`, `project`, `issue`, `sync`, `use`. The generator writes one
|
`init`, `auth`, `project`, `issue`, `sync`, `api`. The generator writes one
|
||||||
`<group>/SKILL.md`, so:
|
`<group>/SKILL.md`, so:
|
||||||
|
|
||||||
| skill | generated region | why |
|
| skill | generated region | why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `issue`, `sync` | yes — the group of the same name | the skill and the group are the same subject |
|
| `issue`, `sync`, `api` | yes — the group of the same name | the skill and the group are the same subject |
|
||||||
| `project` | yes — `init`, `auth`, `config`, `gen` | the flag table `init` and `auth` point at |
|
| `project` | yes — `init`, `auth`, `config`, `gen` | the flag table `init` and `auth` point at |
|
||||||
| `init` | no | it is a *procedure* around one command, and it is operator-only |
|
| `init` | no | it is a *procedure* around one command, and it is operator-only |
|
||||||
| `auth` | no | it is a *procedure* around two, and it must not tempt a model into typing a token |
|
| `auth` | no | it is a *procedure* around two, and it must not tempt a model into typing a token |
|
||||||
| `use` | no | it documents `tea`, which is not this binary |
|
|
||||||
|
|
||||||
The three skills with no region hold no flag tables of their own. They name a
|
`api` is the newest of those and the one that shows what the seam is for: the
|
||||||
|
group exists because `kettle api` is not an issue command, and the skill exists
|
||||||
|
because "which endpoint, and does it paginate" is a map somebody loads on its
|
||||||
|
own. The group was named `api` rather than left as `use` precisely so that the
|
||||||
|
three spellings — group, directory, `/kettle:api` — are one word.
|
||||||
|
|
||||||
|
The two skills with no region hold no flag tables of their own. They name a
|
||||||
command and send the reader to `/kettle:project`, which is the point: a file that
|
command and send the reader to `/kettle:project`, which is the point: a file that
|
||||||
hand-copies a flag list is a file that will disagree with the binary in a month.
|
hand-copies a flag list is a file that will disagree with the binary in a month.
|
||||||
If the generator ever cannot express what a skill needs, the answer is to change
|
If the generator ever cannot express what a skill needs, the answer is to change
|
||||||
@@ -102,9 +107,10 @@ skills/
|
|||||||
issue/ SKILL.md /kettle:issue — the offline commands
|
issue/ SKILL.md /kettle:issue — the offline commands
|
||||||
references/format.md THE canonical issue format; source of truth
|
references/format.md THE canonical issue format; source of truth
|
||||||
sync/ SKILL.md /kettle:sync — the tracker commands
|
sync/ SKILL.md /kettle:sync — the tracker commands
|
||||||
use/ SKILL.md /kettle:use — the `tea` CLI, for the Gitea entities
|
api/ SKILL.md /kettle:api — every Gitea entity with no command of
|
||||||
references/tea/ kettle does not cover: releases, webhooks,
|
its own: pull requests, releases, tags, milestones,
|
||||||
actions, pull requests
|
branches, actions, webhooks. The map of endpoints,
|
||||||
|
and which of them paginate
|
||||||
```
|
```
|
||||||
|
|
||||||
`references/format.md` is the one document here that the binary does not
|
`references/format.md` is the one document here that the binary does not
|
||||||
@@ -138,6 +144,7 @@ and it stays hand-written.
|
|||||||
| the guard hook and its entry in `hooks/hooks.json` | nothing. It existed to stop a `tea` command running under a login the model picked; the binary holds its own credentials and reads the login out of the project's config, so the failure is not expressible any more |
|
| the guard hook and its entry in `hooks/hooks.json` | nothing. It existed to stop a `tea` command running under a login the model picked; the binary holds its own credentials and reads the login out of the project's config, so the failure is not expressible any more |
|
||||||
| `.claude/settings.local.json` → `env.GITEA_LOGIN`, the login pin | `<project>/.kettle/config.yaml` (a login **name**) plus `~/.config/kettle/logins.yaml` (the tokens, 0600, outside every working tree) |
|
| `.claude/settings.local.json` → `env.GITEA_LOGIN`, the login pin | `<project>/.kettle/config.yaml` (a login **name**) plus `~/.config/kettle/logins.yaml` (the tokens, 0600, outside every working tree) |
|
||||||
| the tea plugin's own store marker | `.kettle/`; `kettle init` migrates an older layout in, and each migration is a move |
|
| the tea plugin's own store marker | `.kettle/`; `kettle init` migrates an older layout in, and each migration is a move |
|
||||||
|
| `tea`, Gitea's own CLI, as an external requirement — a skill of reference docs for somebody else's flags, and a second set of logins nothing here could see | `kettle api`, one request through the transport the binary already had. What was 400 lines documenting another tool is now a map of endpoints; what was two credential stores is one |
|
||||||
|
|
||||||
`hooks/agents-sync.sh` is unrelated to any of that and stays. It maintains the
|
`hooks/agents-sync.sh` is unrelated to any of that and stays. It maintains the
|
||||||
`AGENTS.md` convention by repairing the filesystem layout — a real file, with
|
`AGENTS.md` convention by repairing the filesystem layout — a real file, with
|
||||||
|
|||||||
+14
-12
@@ -19,7 +19,7 @@ the tool.
|
|||||||
| `/kettle:project` skill | Generated flag reference for `init`, `auth`, `config`, `gen` |
|
| `/kettle:project` skill | Generated flag reference for `init`, `auth`, `config`, `gen` |
|
||||||
| `/kettle:issue` skill | Issues as units of work — create, read, grep, validate, tick, evict, walk the dependency graph. Entirely offline |
|
| `/kettle:issue` skill | Issues as units of work — create, read, grep, validate, tick, evict, walk the dependency graph. Entirely offline |
|
||||||
| `/kettle:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
| `/kettle:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
||||||
| `/kettle:use` skill | `tea` CLI reference for the Gitea entities kettle does not cover: pulls, releases, milestones, webhooks, actions |
|
| `/kettle:api` skill | Everything else Gitea has, through `kettle api` and the same login: pull requests, releases, tags, milestones, branches, actions, webhooks, notifications |
|
||||||
| `kettle-runner` agent | Subagent on Haiku that runs batches of commands and reports back a receipt — the mechanical half, off your main context |
|
| `kettle-runner` agent | Subagent on Haiku that runs batches of commands and reports back a receipt — the mechanical half, off your main context |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -27,17 +27,18 @@ the tool.
|
|||||||
- **Claude Code** — CLI, desktop app, or IDE extension.
|
- **Claude Code** — CLI, desktop app, or IDE extension.
|
||||||
- **The `kettle` binary, on your `PATH`.** It is not installed for you, and
|
- **The `kettle` binary, on your `PATH`.** It is not installed for you, and
|
||||||
nothing here works without it — see below.
|
nothing here works without it — see below.
|
||||||
- **Python 3** — the two `agents-*` hooks are Python scripts; `python3` must be on
|
- **Python 3** — the `agents-sync` hook is a Python script despite its `.sh`
|
||||||
`$PATH`. Nothing else here needs it.
|
name; `python3` must be on `$PATH`. Nothing else here needs it.
|
||||||
- **`tea`** (optional) — Gitea's own CLI, only for `/kettle:use`. `brew install
|
|
||||||
tea`, or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases).
|
There is **no second CLI to install** — `kettle api`
|
||||||
It keeps its own logins (`tea logins add`), separate from kettle's.
|
reaches every Gitea entity this plugin has no command for, under the login the
|
||||||
|
project already pins.
|
||||||
|
|
||||||
### Installing the binary
|
### Installing the binary
|
||||||
|
|
||||||
Building it needs **Go 1.26** — `cli/go.mod` says so because the Gitea SDK
|
Building it needs **Go 1.26** — `cli/go.mod` says so because the Gitea SDK
|
||||||
requires it. There is no `vendor/` directory: the first build downloads eight
|
requires it. `vendor/` is committed, so a build compiles exactly what is in this
|
||||||
modules, verified against `go.sum`.
|
repository's history.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from a clone of this repository
|
# from a clone of this repository
|
||||||
@@ -147,8 +148,10 @@ command yourself; the saving is in the loop, the retry, and reading somebody
|
|||||||
else's stderr.
|
else's stderr.
|
||||||
|
|
||||||
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no widening the
|
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no widening the
|
||||||
set it was handed, no raw `tea`. A missing type, a failed validation, an unpushed
|
set it was handed, no request it composed itself — `kettle api` goes out as the
|
||||||
dependency come back as a question, not as a guess.
|
caller spelled it, and a deletion is never something it adds. A missing type, a
|
||||||
|
failed validation, an unpushed dependency come back as a question, not as a
|
||||||
|
guess.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
@@ -169,8 +172,7 @@ skills/
|
|||||||
issue/ /kettle:issue — the issue domain, offline
|
issue/ /kettle:issue — the issue domain, offline
|
||||||
references/format.md canonical issue format (identity, types, templates)
|
references/format.md canonical issue format (identity, types, templates)
|
||||||
sync/ /kettle:sync — the bridge to Gitea
|
sync/ /kettle:sync — the bridge to Gitea
|
||||||
use/ /kettle:use — tea CLI reference
|
api/ /kettle:api — every other Gitea entity, by endpoint
|
||||||
references/tea/ command docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`AGENTS.md` carries the same layout with the reasoning behind it, and the
|
`AGENTS.md` carries the same layout with the reasoning behind it, and the
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Load the skill, do not remember the flags:
|
|||||||
`sync-evict`
|
`sync-evict`
|
||||||
- `/kettle:issue` — `new`, `check`, `ac`, `tree`, `index`, `evict`
|
- `/kettle:issue` — `new`, `check`, `ac`, `tree`, `index`, `evict`
|
||||||
- `/kettle:project` — `config`, `auth list`
|
- `/kettle:project` — `config`, `auth list`
|
||||||
|
- `/kettle:api` — `api`, for the Gitea entities that have no command of their own
|
||||||
|
|
||||||
Invoke `Skill` with the one that owns the task at the start and use the generated
|
Invoke `Skill` with the one that owns the task at the start and use the generated
|
||||||
command reference it carries verbatim. That block is written from the binary's
|
command reference it carries verbatim. That block is written from the binary's
|
||||||
@@ -33,10 +34,13 @@ truth if you need it in a hurry.
|
|||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
1. **`kettle` only.** No raw `tea`, no `tea api`, no curl at a tracker. The
|
1. **`kettle` only.** No curl at a tracker, no other CLI, no request you composed
|
||||||
binary carries the project's credentials; there is no login for you to name
|
yourself. The binary carries the project's credentials; there is no login for
|
||||||
and none for you to choose. If a task needs an entity `kettle` does not cover,
|
you to name and none for you to choose. `kettle api` is a kettle command and
|
||||||
that is a finding for the caller, not a command for you to improvise.
|
is yours to run **as the caller spelled it** — endpoint, method and body come
|
||||||
|
from the task, and a `-X DELETE` is never something you add. An entity nobody
|
||||||
|
named an endpoint for is a finding for the caller, not a request for you to
|
||||||
|
improvise.
|
||||||
2. **No writing to issue files.** You have no `Edit` and no `Write`. Commands
|
2. **No writing to issue files.** You have no `Edit` and no `Write`. Commands
|
||||||
write files; you do not. If a task needs a body edited or a metadata field
|
write files; you do not. If a task needs a body edited or a metadata field
|
||||||
changed by hand, stop and say which file and which field. `kettle ac` is the
|
changed by hand, stop and say which file and which field. `kettle ac` is the
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
name: api
|
||||||
|
description: Everything Gitea has that is not an issue — pull requests, releases, tags, branches, milestones, labels, commits, actions, webhooks, notifications, tracked times, users, repositories — reached with `kettle api`, one endpoint per request, under the login the project already pins. Load when the user asks to open or review a PR, cut or edit a release, make a milestone or a tag, look at branches or commits, read notifications or actions, or hit any Gitea endpoint by hand. Issues are NOT handled here: /kettle:issue works on them offline and /kettle:sync moves them to and from the tracker.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /kettle:api — Gitea beyond issues
|
||||||
|
|
||||||
|
`kettle api <endpoint>` is one authenticated request to the Gitea this project is
|
||||||
|
pinned to. No second tool, no second login: the URL, the token and the repository
|
||||||
|
are the ones `/kettle:auth` and `kettle init` already resolved, and the request
|
||||||
|
body is filed under `.kettle/payload/` like every other request kettle makes.
|
||||||
|
|
||||||
|
This skill is the map of what to ask for. `kettle help api` is the flag
|
||||||
|
reference, and it is generated from the binary — the block at the bottom of this
|
||||||
|
file is the same text.
|
||||||
|
|
||||||
|
## Issues are somewhere else
|
||||||
|
|
||||||
|
`kettle api` can reach an issue and must not be used to. An issue read this way
|
||||||
|
comes back as a full JSON payload — every label object, every URL, the whole
|
||||||
|
comment thread — which is precisely what the other two skills exist to keep out
|
||||||
|
of the context window.
|
||||||
|
|
||||||
|
| Skill | Scope |
|
||||||
|
|---|---|
|
||||||
|
| `/kettle:issue` | issues as units of work — create, read, grep, validate, tick, dependency graph. Offline. |
|
||||||
|
| `/kettle:sync` | moving issues between the local store and the tracker — pull, push, comment, close, evict. |
|
||||||
|
|
||||||
|
The one exception is an issue endpoint that is not about the issue's content:
|
||||||
|
`issues/{n}/comments` is also **a pull request's** comment thread, and
|
||||||
|
`PATCH issues/{n}` is also how a pull request's title and body are edited. Gitea
|
||||||
|
numbers issues and pull requests in one sequence and serves both under
|
||||||
|
`/issues/`.
|
||||||
|
|
||||||
|
## What is a command and what is a request
|
||||||
|
|
||||||
|
Reach for the command where there is one: it knows the format, the store and the
|
||||||
|
round trip. Everything else is an endpoint.
|
||||||
|
|
||||||
|
| Subject | How to reach it | Pages? |
|
||||||
|
|---|---|---|
|
||||||
|
| issues (create, read, tick, validate) | `/kettle:issue` — offline, no request at all | — |
|
||||||
|
| issues (pull, push, comment, close, evict) | `/kettle:sync` | handled |
|
||||||
|
| the canonical `type/*` and `severity/*` labels | `kettle labels` | handled |
|
||||||
|
| this repository's own releases, with binaries | `cd cli && make release TAG=v1.2.3` | — |
|
||||||
|
| everything below | `kettle api` | see the column |
|
||||||
|
|
||||||
|
| Entity | Endpoint | Pages? |
|
||||||
|
|---|---|---|
|
||||||
|
| pull requests | `repos/{owner}/{repo}/pulls` | **yes** |
|
||||||
|
| one pull request | `repos/{owner}/{repo}/pulls/{n}` | no |
|
||||||
|
| create a pull request | `POST repos/{owner}/{repo}/pulls` | no |
|
||||||
|
| edit a PR's title or body | `PATCH repos/{owner}/{repo}/issues/{n}` | no |
|
||||||
|
| a PR's or issue's comments | `repos/{owner}/{repo}/issues/{n}/comments` | **yes** |
|
||||||
|
| edit one comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` | no |
|
||||||
|
| reviews on a PR | `repos/{owner}/{repo}/pulls/{n}/reviews` | **yes** |
|
||||||
|
| merge a PR | `POST repos/{owner}/{repo}/pulls/{n}/merge` | no |
|
||||||
|
| releases | `repos/{owner}/{repo}/releases` | **yes** |
|
||||||
|
| one release by tag | `repos/{owner}/{repo}/releases/tags/{tag}` | no |
|
||||||
|
| tags | `repos/{owner}/{repo}/tags` | **yes** |
|
||||||
|
| branches | `repos/{owner}/{repo}/branches` | **yes** |
|
||||||
|
| commits | `repos/{owner}/{repo}/commits` | **yes** |
|
||||||
|
| milestones | `repos/{owner}/{repo}/milestones` | **yes** |
|
||||||
|
| labels (all of them, not just canonical) | `repos/{owner}/{repo}/labels` | **yes** |
|
||||||
|
| webhooks | `repos/{owner}/{repo}/hooks` | **yes** |
|
||||||
|
| action tasks | `repos/{owner}/{repo}/actions/tasks` | **yes** |
|
||||||
|
| tracked times | `repos/{owner}/{repo}/times` | **yes** |
|
||||||
|
| the repository itself | `repos/{owner}/{repo}` | no |
|
||||||
|
| notifications | `notifications` | **yes** |
|
||||||
|
| who this token is | `user` | no |
|
||||||
|
| an organization's repositories | `orgs/{org}/repos` | **yes** |
|
||||||
|
|
||||||
|
`{owner}` and `{repo}` are filled in from the project's configuration. A path
|
||||||
|
that names a repository in full is left alone — `repos/other-owner/other/releases`
|
||||||
|
reads another repository on the same instance, which is why there is no `--repo`
|
||||||
|
flag. Another **instance** is `KETTLE_URL` and `KETTLE_TOKEN`, not a flag.
|
||||||
|
|
||||||
|
What the instance actually serves is its own version's business; its API docs at
|
||||||
|
`<instance-url>/api/swagger` are the authority when an endpoint answers 404.
|
||||||
|
|
||||||
|
## Pagination is yours
|
||||||
|
|
||||||
|
**One invocation is one HTTP request.** `kettle api` never follows a list to its
|
||||||
|
end, because a passthrough that silently stitched pages together would report as
|
||||||
|
one answer something that was several.
|
||||||
|
|
||||||
|
So for every row marked **yes** above:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50' # first page, 50 rows
|
||||||
|
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50&page=2'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `limit` is capped by the instance (`MAX_RESPONSE_ITEMS`, 50 by default); the
|
||||||
|
default page size is 30.
|
||||||
|
- **A short page is the last one.** Ask for 50, count what came back: fewer than
|
||||||
|
50 means there is no page 3. That is the same rule the binary's own listings
|
||||||
|
use, and it needs no response headers.
|
||||||
|
- Quote any endpoint holding `?` or `&`, or the shell takes it apart.
|
||||||
|
- Walking many pages of anything into your own context is a mistake before it is
|
||||||
|
a request. Narrow the query (`state=`, `since=`, `q=`), or pipe through `jq`
|
||||||
|
and keep the two fields you needed.
|
||||||
|
|
||||||
|
## Writing a body
|
||||||
|
|
||||||
|
Two ways, and the choice is about the body, not the endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# small and flat: every value is a string
|
||||||
|
kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments
|
||||||
|
|
||||||
|
# anything real — multi-line, markdown, booleans, numbers, nesting
|
||||||
|
mkdir -p tmp/release
|
||||||
|
cat > tmp/release/v0-2-0.json <<'EOF'
|
||||||
|
{"tag_name": "v0.2.0", "name": "v0.2.0", "draft": false,
|
||||||
|
"body": "## Changes\n\nMulti-line markdown with `code`."}
|
||||||
|
EOF
|
||||||
|
kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases
|
||||||
|
```
|
||||||
|
|
||||||
|
- A body implies `POST`; anything else is `-X PUT`, `-X PATCH`, `-X DELETE`.
|
||||||
|
- Newlines inside a JSON string are `\n`. Composing from a file:
|
||||||
|
`jq -Rs '{body: .}' < body.md > tmp/pull/x.json`.
|
||||||
|
- `--field` values are **always strings**. A `draft: false` or a number is a
|
||||||
|
`--data` body — guessing types is how a `tag_name` of `1.0` goes up as a
|
||||||
|
number.
|
||||||
|
- Keep `tmp/` gitignored and keep the file: a `PATCH` is usually the same body
|
||||||
|
with one line changed. `kettle` files its own copy under `.kettle/payload/`
|
||||||
|
automatically; that directory is the transport's and nothing hand-made goes in
|
||||||
|
it.
|
||||||
|
- Attachments are `multipart/form-data` and this command sends JSON. Upload
|
||||||
|
release binaries with the release tooling (`make release`), or the web UI.
|
||||||
|
|
||||||
|
## Deleting
|
||||||
|
|
||||||
|
`-X DELETE` needs `--yes` in the same invocation, and the refusal happens before
|
||||||
|
anything is sent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12
|
||||||
|
```
|
||||||
|
|
||||||
|
That flag is the whole gate. **Whether a thing should be deleted is the
|
||||||
|
operator's call, not a step in a plan** — ask, do not assume, and never widen a
|
||||||
|
deletion past what was named.
|
||||||
|
|
||||||
|
## When it says 403
|
||||||
|
|
||||||
|
Gitea scopes a token as `<read|write>:<category>`, and a token minted to file
|
||||||
|
issues carries `write:issue` and nothing else. Releases, pull requests, branches,
|
||||||
|
tags and actions are all `repository`, so that token answers **403 on the first
|
||||||
|
`kettle api` outside issues** — and the 403 names no scope.
|
||||||
|
|
||||||
|
`kettle auth list` shows what each login on this machine recorded; `kettle config`
|
||||||
|
shows what this project resolved. Nothing can be read back off the instance
|
||||||
|
(Gitea's own token listing needs a password, not a token), so a scope that was
|
||||||
|
never written down is a scope nobody knows. Minting a new token is the operator's
|
||||||
|
job, in the web UI — `/kettle:auth` has the procedure.
|
||||||
|
|
||||||
|
## What is not an API call at all
|
||||||
|
|
||||||
|
| Want | Do |
|
||||||
|
|---|---|
|
||||||
|
| check out a PR branch, clone, push | `git`. This is git's job and always was |
|
||||||
|
| who am I | `kettle api user` |
|
||||||
|
| open something in a browser | nothing here; hand the user the URL |
|
||||||
|
| add a login, list logins, ssh keys | `/kettle:auth`, and adding one is the operator's |
|
||||||
|
| administer users or the instance | nothing here. Not an agent's work |
|
||||||
|
|
||||||
|
The canonical issue format lives in
|
||||||
|
[`../issue/references/format.md`](../issue/references/format.md) — it describes
|
||||||
|
local files, not requests.
|
||||||
|
|
||||||
|
<!-- kettle:gen -->
|
||||||
|
**Generated from the kettle command registry by `kettle gen skills`.** Everything between the two markers is replaced on the next run — hand-written prose belongs outside them.
|
||||||
|
|
||||||
|
## `kettle api <endpoint>`
|
||||||
|
|
||||||
|
one request to this project's Gitea, for everything that is not an issue
|
||||||
|
|
||||||
|
Releases, pull requests, milestones, branches, tags, actions, webhooks,
|
||||||
|
notifications: everything Gitea has that this binary has no command for. One
|
||||||
|
invocation is ONE request — the credentials, the repository and the payload
|
||||||
|
scratchpad are the ones this project already resolved, so there is nothing to
|
||||||
|
configure and no second tool to log in.
|
||||||
|
|
||||||
|
THE ENDPOINT IS SPELLED THE WAY GITEA'S OWN DOCUMENTATION SPELLS IT. A bare path
|
||||||
|
is taken as relative to `/api/v1/`; a path that already begins `/api/` is sent as it
|
||||||
|
stands, which is how anything outside v1 is reached; a full URL is allowed only
|
||||||
|
on the instance this project points at, because every request here carries the
|
||||||
|
project's token in a header and a URL somewhere else would hand that token over.
|
||||||
|
`{owner}` and `{repo}` are filled in from the project's configuration. Quote an
|
||||||
|
endpoint that contains ? or & or the shell will take it apart.
|
||||||
|
|
||||||
|
ANOTHER REPOSITORY NEEDS NO FLAG — write its address into the path
|
||||||
|
(`repos/other-owner/other-repo/releases`) and nothing is substituted. There is no
|
||||||
|
--repo and no --login here for the same reason there is none on push or pull:
|
||||||
|
which login a project runs under is a fact about the project. Another INSTANCE
|
||||||
|
is KETTLE_URL and KETTLE_TOKEN, which is also what a CI run uses.
|
||||||
|
|
||||||
|
THE ANSWER IS THE SERVER'S BYTES ON STDOUT, unparsed and unreformatted — pipe it
|
||||||
|
to jq, redirect it to a file. There is no flag that names an output file: in
|
||||||
|
this tree --out is the issue store, and one word meaning two things is exactly
|
||||||
|
the trap the tool this replaces set with an -o that wrote a file called "json".
|
||||||
|
|
||||||
|
IT DOES NOT PAGINATE. One call is one request, so a listing answers with one
|
||||||
|
page: ask for the next with ?page=2, and for a bigger one with ?limit=50 (the
|
||||||
|
server's own default is 30, its maximum is usually 50). A passthrough that
|
||||||
|
stitched pages together silently would report as one answer something that was
|
||||||
|
several.
|
||||||
|
|
||||||
|
ISSUES ARE NOT THIS COMMAND'S JOB even though it can reach them. An issue read
|
||||||
|
this way arrives as a full JSON payload — every comment, every label object,
|
||||||
|
every URL — which is what /kettle:issue and /kettle:sync exist to keep out of a
|
||||||
|
context window. Use pull, push, comment and close.
|
||||||
|
|
||||||
|
A 403 here is usually the token rather than the request: a token minted for
|
||||||
|
issues carries write:issue, and releases, pull requests, branches and tags are
|
||||||
|
all under repository. `kettle auth list` shows what each login records.
|
||||||
|
|
||||||
|
-X DELETE NEEDS --yes. Everything else goes through as typed; a deletion does
|
||||||
|
not, because a flag typed on purpose is an operator's decision and the URL of a
|
||||||
|
release is one character away from the URL of the wrong release.
|
||||||
|
|
||||||
|
What it cannot do: an upload. Release attachments are multipart/form-data and
|
||||||
|
this sends JSON — the release tooling in cmd/release does those.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--X` | — | the same flag as --method, spelled the way curl and the tool this replaces spell it |
|
||||||
|
| `--data` | — | the request body: @file, @- for standard input, or the JSON itself |
|
||||||
|
| `--field` | — | key=value, added to a JSON body as a string; repeatable |
|
||||||
|
| `--method` | — | GET, POST, PUT, PATCH or DELETE (default GET, or POST when there is a body) |
|
||||||
|
| `--status` | `false` | print the status line on standard error |
|
||||||
|
| `--yes` | `false` | confirm a DELETE |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle api repos/{owner}/{repo}/releases # the latest page of releases, as JSON
|
||||||
|
kettle api user # who this project's token belongs to
|
||||||
|
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50' # quote anything with ? or & in it
|
||||||
|
kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases # a body from a file; POST is implied
|
||||||
|
kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments # a small body without a file
|
||||||
|
kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12 # a deletion, said out loud
|
||||||
|
kettle api repos/{owner}/{repo}/milestones | jq '.[].title' # the bytes are the server's; jq is yours
|
||||||
|
```
|
||||||
|
<!-- /kettle:gen -->
|
||||||
@@ -85,9 +85,33 @@ repo claude-skills/marketplace
|
|||||||
| `401` / `403` from a sync command | report it verbatim. Do **not** try another login, and do not edit or remove one to route around it — that is somebody's identity, not a setting |
|
| `401` / `403` from a sync command | report it verbatim. Do **not** try another login, and do not edit or remove one to route around it — that is somebody's identity, not a setting |
|
||||||
| `token none` in `kettle config` | a name is pinned but no credential answers to it |
|
| `token none` in `kettle config` | a name is pinned but no credential answers to it |
|
||||||
|
|
||||||
**`tea` does not read any of this.** The `tea` CLI keeps its own configuration
|
## Scopes: what the token is allowed to do
|
||||||
under `$XDG_CONFIG_HOME/tea` and its own logins (`tea logins list`), and
|
|
||||||
configuring one tool configures nothing in the other — see `/kettle:use`.
|
Gitea mints a token with scopes, spelled `<read|write>:<category>`. A token made
|
||||||
|
for issues carries `write:issue` — and that is enough for everything
|
||||||
|
`/kettle:issue` and `/kettle:sync` do, and **not** enough for anything
|
||||||
|
`/kettle:api` reaches: releases, pull requests, branches, tags and actions all
|
||||||
|
sit under `repository`.
|
||||||
|
|
||||||
|
| doing | needs |
|
||||||
|
|---|---|
|
||||||
|
| pull, push, comment, close, evict | `write:issue` |
|
||||||
|
| `kettle labels` | `write:issue` |
|
||||||
|
| `kettle api` on releases, PRs, tags, branches, actions | `write:repository` too |
|
||||||
|
| reading any of those without writing | the `read:` half is enough |
|
||||||
|
|
||||||
|
`kettle auth add --scopes write:issue,write:repository` writes that down beside
|
||||||
|
the login. **It is a note and nothing else** — nothing is checked against it and
|
||||||
|
nothing is refused because of it. It is worth writing down because the instance
|
||||||
|
will not answer the question: Gitea's own token listing needs a password rather
|
||||||
|
than a token, so a token cannot be asked what it may do. `kettle auth list` and
|
||||||
|
`kettle config` show what was recorded; `(not recorded)` means nobody wrote it
|
||||||
|
down, never "none".
|
||||||
|
|
||||||
|
A **403** from a sync command or from `kettle api` is usually this and says so.
|
||||||
|
Minting a new token is the operator's job in the web UI — the same flow as step
|
||||||
|
2 above, with both scopes ticked this time. Never remove or re-point a login to
|
||||||
|
route around a 403.
|
||||||
|
|
||||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story. Stop and
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story. Stop and
|
||||||
tell the operator to install it: `cd cli && go build -o ~/.local/bin/kettle
|
tell the operator to install it: `cd cli && go build -o ~/.local/bin/kettle
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ an issue. It is the single source of truth for identity, metadata, types,
|
|||||||
labels, templates, and language rules.
|
labels, templates, and language rules.
|
||||||
|
|
||||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
||||||
Python scripts this plugin used to ship are gone and `tea` is not a substitute.
|
Python scripts this plugin used to ship are gone and no other CLI is a
|
||||||
|
substitute.
|
||||||
Stop and tell the operator to install it: `cd cli && go build -o
|
Stop and tell the operator to install it: `cd cli && go build -o
|
||||||
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||||
**go 1.26**), or `go install
|
**go 1.26**), or `go install
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ milestone: v0.2
|
|||||||
depends: [migrate-schema]
|
depends: [migrate-schema]
|
||||||
origin: gitea
|
origin: gitea
|
||||||
branch: feat/wire-sqlc
|
branch: feat/wire-sqlc
|
||||||
gitea: claude-skills/tea#42
|
gitea: claude-skills/marketplace#42
|
||||||
remote-updated: 2026-08-09T18:24:01Z
|
remote-updated: 2026-08-09T18:24:01Z
|
||||||
synced: 2026-08-09T18:40:00Z
|
synced: 2026-08-09T18:40:00Z
|
||||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
url: https://git.noodles.cam/claude-skills/marketplace/issues/42
|
||||||
---
|
---
|
||||||
# Wire sqlc into the appclick repo layer
|
# Wire sqlc into the appclick repo layer
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ nothing on its own, which is what makes it safe inside a working tree.
|
|||||||
`KETTLE_URL` and `KETTLE_TOKEN` each override the file they shadow.
|
`KETTLE_URL` and `KETTLE_TOKEN` each override the file they shadow.
|
||||||
|
|
||||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — no
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story — no
|
||||||
script and no `tea` invocation substitutes for it. Stop and tell the operator to
|
script and no other CLI substitutes for it. Stop and tell the operator to
|
||||||
install it: `cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle` in the
|
install it: `cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle` in the
|
||||||
marketplace repository (go.mod requires **go 1.26**), or
|
marketplace repository (go.mod requires **go 1.26**), or
|
||||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||||
@@ -47,9 +47,19 @@ argument is in the shell history the moment it is typed:
|
|||||||
|
|
||||||
`list` never prints a token. There is no flag to make it.
|
`list` never prints a token. There is no flag to make it.
|
||||||
|
|
||||||
|
--scopes RECORDS WHAT THE TOKEN WAS MINTED WITH, and records is all it does:
|
||||||
|
nothing is checked against it and nothing is refused because of it. It is worth
|
||||||
|
writing down because the instance will not answer the question — Gitea's own
|
||||||
|
token listing needs a password, not a token, so a token cannot be asked what it
|
||||||
|
may do. Gitea spells them <read|write>:<category>; issues need `write:issue`,
|
||||||
|
and everything `kettle api` reaches outside issues — releases, pull requests,
|
||||||
|
branches, tags, actions — is `repository`. A token minted for issues alone
|
||||||
|
answers 403 there, and the 403 names no scope.
|
||||||
|
|
||||||
| flag | default | what it does |
|
| flag | default | what it does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--name` | — | login name (add) |
|
| `--name` | — | login name (add) |
|
||||||
|
| `--scopes` | — | what the token was minted with, comma separated, e.g. write:issue,write:repository; documentation only (add) |
|
||||||
| `--token` | — | token, if you would rather not use stdin (add) |
|
| `--token` | — | token, if you would rather not use stdin (add) |
|
||||||
| `--url` | — | instance URL, e.g. https://git.example.com (add) |
|
| `--url` | — | instance URL, e.g. https://git.example.com (add) |
|
||||||
| `--user` | — | account this token belongs to; documentation only (add) |
|
| `--user` | — | account this token belongs to; documentation only (add) |
|
||||||
@@ -57,6 +67,7 @@ argument is in the shell history the moment it is typed:
|
|||||||
```bash
|
```bash
|
||||||
kettle auth list # what this machine holds
|
kettle auth list # what this machine holds
|
||||||
pass show gitea | kettle auth add --name noodles --url https://git.example.com # add one, token on stdin
|
pass show gitea | kettle auth add --name noodles --url https://git.example.com # add one, token on stdin
|
||||||
|
kettle auth add --name noodles --url https://git.example.com --scopes write:issue,write:repository < t.txt # and write down what it can do
|
||||||
kettle auth remove noodles # forget it
|
kettle auth remove noodles # forget it
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ second-guesses it. Knowledge flows one way: delete the tracker from the world an
|
|||||||
the issue domain does not notice.
|
the issue domain does not notice.
|
||||||
|
|
||||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
||||||
Python scripts this plugin used to ship are gone and raw `tea` is not a
|
Python scripts this plugin used to ship are gone and no other CLI is a
|
||||||
substitute. Stop and tell the operator to install it: `cd cli && go build -o
|
substitute. Stop and tell the operator to install it: `cd cli && go build -o
|
||||||
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||||
**go 1.26**), or `go install
|
**go 1.26**), or `go install
|
||||||
@@ -38,11 +38,12 @@ No login, an unknown login name, a 401: report it and stop — `/kettle:auth`.
|
|||||||
|
|
||||||
## Never read an issue through a raw API dump
|
## Never read an issue through a raw API dump
|
||||||
|
|
||||||
`tea issues 42 -o json` and `tea api …/issues/42` put the whole payload —
|
`kettle api repos/{owner}/{repo}/issues/42` will answer, and answering is the
|
||||||
avatars, nested user objects, every comment body — into your context whether you
|
problem: the whole payload — avatars, nested user objects, every comment body —
|
||||||
need it or not. `kettle pull` writes flat markdown and prints a compact line per
|
lands in your context whether you need it or not. `kettle pull` writes flat
|
||||||
issue; `kettle remote` lists the tracker without writing anything at all. Use
|
markdown and prints a compact line per issue; `kettle remote` lists the tracker
|
||||||
those.
|
without writing anything at all. Use those. `/kettle:api` is for the entities
|
||||||
|
that have no command, and it says the same thing from its side.
|
||||||
|
|
||||||
## The round trip is one rule
|
## The round trip is one rule
|
||||||
|
|
||||||
@@ -183,9 +184,9 @@ request bodies are debris of the transport, and a scratchpad inside a store make
|
|||||||
`ls .kettle/issues` lie about what exists. Nothing in it is anybody's only copy —
|
`ls .kettle/issues` lie about what exists. Nothing in it is anybody's only copy —
|
||||||
deleting it costs nothing. A run that sends nothing leaves no directory behind.
|
deleting it costs nothing. A run that sends nothing leaves no directory behind.
|
||||||
|
|
||||||
For Gitea entities `kettle` does not cover — releases, webhooks, actions, pull
|
For Gitea entities `kettle` has no command for — releases, webhooks, actions,
|
||||||
requests — the tool is `tea`, and it keeps its own configuration and its own
|
pull requests — `kettle api` sends the request under this same login, into this
|
||||||
logins. `/kettle:use`.
|
same scratchpad. `/kettle:api`.
|
||||||
|
|
||||||
The commands themselves follow. Their usage lines, flags, defaults and examples
|
The commands themselves follow. Their usage lines, flags, defaults and examples
|
||||||
are generated from the binary's own command registry, so they cannot disagree
|
are generated from the binary's own command registry, so they cannot disagree
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
---
|
|
||||||
name: use
|
|
||||||
description: Reference docs for the `tea` CLI — Gitea's own command-line client, and the way to reach every Gitea entity the `kettle` binary does not cover. Load when the user asks about pull requests, releases, milestones, labels, repos, branches, actions, webhooks, notifications or times, to look up the right `tea` command and flags. `tea` keeps its own configuration and its own logins, entirely separate from kettle's. Issues are NOT handled here: /kettle:issue works on them offline and /kettle:sync moves them to and from the tracker.
|
|
||||||
---
|
|
||||||
|
|
||||||
# /kettle:use — tea CLI reference
|
|
||||||
|
|
||||||
Reference material for `tea`, Gitea's official command-line client. Use these
|
|
||||||
docs to look up commands, flags, filters and output fields before running `tea`
|
|
||||||
via Bash.
|
|
||||||
|
|
||||||
`kettle` covers issues and nothing else. Everything else Gitea has — pulls,
|
|
||||||
releases, milestones, labels, repos, branches, actions, webhooks, notifications,
|
|
||||||
times — is reached through `tea`, and this skill is how.
|
|
||||||
|
|
||||||
## Issues are somewhere else
|
|
||||||
|
|
||||||
Do **not** reach for `tea issues` or `tea api …/issues/…` to read or create an
|
|
||||||
issue. Two skills own that, and they keep the payload out of your context:
|
|
||||||
|
|
||||||
| Skill | Scope |
|
|
||||||
|---|---|
|
|
||||||
| `/kettle:issue` | issues as units of work — create, read, grep, validate, tick, dependency graph. Offline. |
|
|
||||||
| `/kettle:sync` | moving issues between the local store and the tracker — pull, push, comment, close, evict. |
|
|
||||||
|
|
||||||
## Login: `tea` has its own configuration, and it is not kettle's
|
|
||||||
|
|
||||||
Two tools, two credential stores, no connection between them:
|
|
||||||
|
|
||||||
| tool | where its logins live | how they are managed |
|
|
||||||
|---|---|---|
|
|
||||||
| `tea` | `$XDG_CONFIG_HOME/tea` | `tea logins list`, `tea logins add` (interactive), `tea logins default` |
|
|
||||||
| `kettle` | `~/.config/kettle/logins.yaml` + `<project>/.kettle/config.yaml` | `kettle auth`, `kettle init --login` (`/kettle:auth`) |
|
|
||||||
|
|
||||||
**Configuring one configures nothing in the other.** `/kettle:auth` does not give
|
|
||||||
`tea` a credential, and `tea logins add` does not give `kettle` one. A project
|
|
||||||
whose `kettle` commands work fine can still have no `tea` login at all, and the
|
|
||||||
error you get will be about the login `tea` chose for itself.
|
|
||||||
|
|
||||||
**There is no `$GITEA_LOGIN` placeholder and no hook that substitutes one.** The
|
|
||||||
PreToolUse guard that used to rewrite it was deleted along with the Python
|
|
||||||
scripts; writing `--login "$GITEA_LOGIN"` now passes an empty variable to `tea`
|
|
||||||
and fails in a way that reads like a `tea` bug. If you find that spelling
|
|
||||||
anywhere, it is stale.
|
|
||||||
|
|
||||||
How to name a login honestly:
|
|
||||||
|
|
||||||
- Inside a checkout, `tea` auto-detects owner, repo and login from the git
|
|
||||||
remote. That is usually right and usually enough — run the command without
|
|
||||||
`--login`.
|
|
||||||
- When the machine holds more than one login, or you are outside a checkout,
|
|
||||||
pass `--login <name>` with a name out of `tea logins list`. **Which one is the
|
|
||||||
operator's call**: ask with `AskUserQuestion` rather than picking the one that
|
|
||||||
looks likely. A wrong identity writes to a real tracker under somebody else's
|
|
||||||
account.
|
|
||||||
- `no gitea login detected, falling back to login '…'` is a **hard failure**, not
|
|
||||||
a warning. Stop, do not act on the result, surface the line.
|
|
||||||
- **Never mutate login state**: no `tea logins add/edit/delete/default`, no
|
|
||||||
`tea logout`. `tea logins list` is the only login command that is yours to run,
|
|
||||||
and adding a login is interactive — the operator does it in their own terminal.
|
|
||||||
|
|
||||||
## How to use
|
|
||||||
|
|
||||||
1. Identify the entity in the request: pulls, labels, milestones, releases,
|
|
||||||
times, repos, branches, actions, webhooks, notifications, etc.
|
|
||||||
2. Find the matching command in the index below.
|
|
||||||
3. Run it via Bash, e.g. `tea pulls list --repo owner/repo --state open`.
|
|
||||||
|
|
||||||
`tea` auto-detects owner/repo from `$PWD` inside a git repo; otherwise pass
|
|
||||||
`--repo owner/repo` (or `-r`).
|
|
||||||
|
|
||||||
### `--repo` takes a slug — except where a checkout is required
|
|
||||||
|
|
||||||
A few commands touch local git, not just the API, and for those `--repo` **must
|
|
||||||
be a path to a checkout**; a slug is rejected:
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: local repository required: execute from a repo dir, or specify a path with --repo
|
|
||||||
```
|
|
||||||
|
|
||||||
The message reads like the flag is missing even when it was passed. Confirmed
|
|
||||||
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
|
|
||||||
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
|
|
||||||
`labels`, `issues` — takes the slug from any directory.
|
|
||||||
|
|
||||||
Three working forms for `pulls create`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. cwd inside the checkout, no --repo at all
|
|
||||||
tea pulls create --head feat/x --base main --title "…" --description "…"
|
|
||||||
|
|
||||||
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
|
|
||||||
# point it at the main checkout)
|
|
||||||
tea pulls create --repo /path/to/checkout \
|
|
||||||
--head feat/x --base main --title "…" --description "…"
|
|
||||||
|
|
||||||
# 3. no checkout in reach — POST it, where owner/repo is a slug again
|
|
||||||
tea api -X POST -d @tmp/pull/x.json repos/{owner}/{repo}/pulls
|
|
||||||
```
|
|
||||||
|
|
||||||
## Index
|
|
||||||
|
|
||||||
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
|
||||||
- [ENTITIES](references/tea/entities.md) — issues, pulls, labels, milestones, releases, times, repos, branches, actions, webhooks, comment
|
|
||||||
- [HELPERS](references/tea/helpers.md) — open, notifications, clone, api
|
|
||||||
- [MISC](references/tea/misc.md) — whoami, admin
|
|
||||||
- [SETUP](references/tea/setup.md) — logins, logout, ssh-keys
|
|
||||||
|
|
||||||
The canonical issue format lives in
|
|
||||||
[`../issue/references/format.md`](../issue/references/format.md) — it describes
|
|
||||||
local files, not `tea` commands.
|
|
||||||
|
|
||||||
## Rich payloads — write to `$PWD/tmp/` first, then `tea api`
|
|
||||||
|
|
||||||
Entity subcommands (`tea comment`, `tea pulls create`, `tea releases create`, …)
|
|
||||||
are built for humans at a TTY. With a large or formatted body they can hang
|
|
||||||
silently — an empty-looking positional arg triggers the `$EDITOR` fallback, or a
|
|
||||||
scope/confirm prompt waits on a TTY that doesn't exist. The harness eventually
|
|
||||||
kills the process (e.g. exit 144 = 128 + SIGURG on macOS).
|
|
||||||
|
|
||||||
**Rule:** for any non-trivial body (multi-line, or containing markdown / code
|
|
||||||
fences / backticks / pipes / tables), bypass entity commands. Save the full
|
|
||||||
request payload to `$PWD/tmp/` first, then POST via `tea api`.
|
|
||||||
|
|
||||||
Issues and issue comments are already wrapped — use `/kettle:sync` rather than
|
|
||||||
hand-rolling their JSON. `.kettle/payload/` is kettle's own scratchpad and is
|
|
||||||
written by kettle only; do not put hand-made bodies there. The procedure below
|
|
||||||
covers everything else.
|
|
||||||
|
|
||||||
### Procedure
|
|
||||||
|
|
||||||
1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is
|
|
||||||
`pull`, `release`, etc.
|
|
||||||
2. Write the **complete request body as JSON** to `$PWD/tmp/{kind}/<slug>.json`.
|
|
||||||
One file = one request. Use a quoted heredoc to avoid shell expansion:
|
|
||||||
```bash
|
|
||||||
mkdir -p tmp/release
|
|
||||||
cat > tmp/release/v0-2-0.json <<'EOF'
|
|
||||||
{"tag_name": "v0.2.0", "name": "v0.2.0", "body": "## Changes\n\nMulti-line markdown with `code`."}
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
Newlines inside the body must be encoded as `\n` in the JSON string. If
|
|
||||||
composing programmatically, pipe through
|
|
||||||
`jq -Rs '{body: .}' < body.md > tmp/release/v0-2-0.json`.
|
|
||||||
3. POST with `tea api`, passing the file with `-d @<path>`:
|
|
||||||
```bash
|
|
||||||
tea api -X POST -d @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases
|
|
||||||
```
|
|
||||||
4. Keep the file. `tmp/` should be gitignored; the saved payload is useful for
|
|
||||||
retries, edits (`PATCH`), and debugging failed posts.
|
|
||||||
|
|
||||||
### Common endpoints
|
|
||||||
|
|
||||||
| Action | Method + endpoint |
|
|
||||||
|---|---|
|
|
||||||
| Create PR | `POST repos/{owner}/{repo}/pulls` |
|
|
||||||
| Edit PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` |
|
|
||||||
| Comment on a PR | `POST repos/{owner}/{repo}/issues/{n}/comments` |
|
|
||||||
| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` |
|
|
||||||
| Create release | `POST repos/{owner}/{repo}/releases` |
|
|
||||||
| Create milestone | `POST repos/{owner}/{repo}/milestones` |
|
|
||||||
|
|
||||||
Short single-line bodies (e.g. `tea comment 42 "lgtm"`) are still fine via
|
|
||||||
entity commands.
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
- Pass `-o json` for structured output when parsing programmatically — on
|
|
||||||
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
|
|
||||||
writes the response body to a file called `json` and leaves stdout empty.
|
|
||||||
The response is already JSON, so there is nothing to format; use `-` for
|
|
||||||
stdout, or leave the flag off.
|
|
||||||
- Use `--fields, -f` to narrow columns.
|
|
||||||
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
|
||||||
- A `tea` command that fails on identity is a login problem in **tea's** own
|
|
||||||
config, never in kettle's — `tea logins list`, and the operator decides.
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# tea CLI — ENTITIES
|
|
||||||
|
|
||||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
|
||||||
|
|
||||||
## `tea issues` (aliases: `issue`, `i`)
|
|
||||||
Without args lists issues; with `<index>` shows issue detail.
|
|
||||||
|
|
||||||
Shared filters: `--state {all|open|closed}` (default: open), `--kind {issues|pulls|all}`, `--keyword/-k`, `--labels/-L`, `--milestones/-m`, `--author/-A`, `--assignee/-a`, `--mentions/-M`, `--owner/--org`, `--from/-F`, `--until/-u`, `--comments`. Available fields: `index,state,kind,author,author-id,url,title,body,created,updated,deadline,assignees,milestone,labels,comments,owner,repo`.
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
- `list, ls` — list (same filters as above).
|
|
||||||
- `create, c` — create an issue. Options: `--title/-t`, `--description/-d`, `--assignees/-a`, `--labels/-L`, `--milestone/-m`, `--deadline/-D`, `--referenced-version/-v` (commit hash or tag).
|
|
||||||
- `edit, e <idx>...` — edit. `--title`, `--description`, `--add-assignees/-a`, `--add-labels/-L`, `--remove-labels`, `--milestone`, `--deadline`, `--referenced-version`. To unset a value pass an empty string (`--milestone ""`).
|
|
||||||
- `reopen, open <idx>...`
|
|
||||||
- `close <idx>...`
|
|
||||||
|
|
||||||
## `tea pulls` (aliases: `pull`, `pr`)
|
|
||||||
Without args lists PRs; with `<index>` shows PR detail. Fields: `index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci`.
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
- `list, ls` (`--state`)
|
|
||||||
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing. Needs a checkout, same as `create`: `--repo` is a path here, not a slug.
|
|
||||||
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash. Needs a checkout, same as `create`.
|
|
||||||
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
|
|
||||||
**Needs a local checkout.** `--repo owner/repo` is *not* accepted here — the
|
|
||||||
slug fails with `local repository required: execute from a repo dir, or
|
|
||||||
specify a path with --repo`, whose advice reads like the flag was missing.
|
|
||||||
Run it with cwd inside the checkout and no `--repo`, or pass `--repo
|
|
||||||
/path/to/checkout`. From a git worktree, point `--repo` at the main
|
|
||||||
checkout. With no checkout in reach, `POST repos/{owner}/{repo}/pulls`
|
|
||||||
through `tea api`, which takes the slug.
|
|
||||||
- `close <idx>...`, `reopen, open <idx>...`
|
|
||||||
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
|
||||||
- `review <idx>` — interactive review.
|
|
||||||
- `approve, lgtm, a <idx> [comment]`
|
|
||||||
- `reject <idx> <reason>`
|
|
||||||
- `merge, m <idx>` — `--style/-s {merge|rebase|squash|rebase-merge}` (default merge), `--title/-t`, `--message/-m`.
|
|
||||||
- `review-comments, rc <idx>` — list review comments. Fields: `id,body,reviewer,path,line,resolver,created,updated,url`.
|
|
||||||
- `resolve <comment-id>` / `unresolve <comment-id>`
|
|
||||||
|
|
||||||
## `tea labels` (alias: `label`)
|
|
||||||
- `list, ls` — `--save/-s` dumps labels to a file.
|
|
||||||
- `create, c` — `--name`, `--color`, `--description`, `--file` (bulk import from file).
|
|
||||||
- `update` — `--id`, `--name`, `--color`, `--description`.
|
|
||||||
- `delete, rm` — `--id`.
|
|
||||||
|
|
||||||
## `tea milestones` (aliases: `milestone`, `ms`)
|
|
||||||
Fields: `title,state,items_open,items_closed,items,duedate,description,created,updated,closed,id`.
|
|
||||||
|
|
||||||
- `list, ls` (`--state`)
|
|
||||||
- `create, c` — `--title/-t`, `--description/-d`, `--deadline/--expires/-x`, `--state`.
|
|
||||||
- `close <name>...` — `--force/-f` deletes instead of closing.
|
|
||||||
- `reopen, open <name>...`
|
|
||||||
- `delete, rm <name>`
|
|
||||||
- `issues, i <name>` — manage milestone contents:
|
|
||||||
- `add, a <name> <issue-idx>`
|
|
||||||
- `remove, r <name> <issue-idx>`
|
|
||||||
|
|
||||||
## `tea releases` (aliases: `release`, `r`)
|
|
||||||
- `list, ls`
|
|
||||||
- `create, c [<tag>]` — `--tag`, `--target` (branch/commit), `--title/-t`, `--note/-n`, `--note-file/-f`, `--draft/-d`, `--prerelease/-p`, `--asset/-a <path>` (repeatable).
|
|
||||||
- `edit, e <tag>...` — `--tag`, `--target`, `--title/-t`, `--note/-n`, `--draft/-d <bool>`, `--prerelease/-p <bool>`.
|
|
||||||
- `delete, rm <tag>...` — `--confirm/-y` required; `--delete-tag` also removes the git tag.
|
|
||||||
- `assets, asset, a` — manage release attachments:
|
|
||||||
- `list, ls <tag>`
|
|
||||||
- `create, c <tag> <asset>...`
|
|
||||||
- `delete, rm <tag> <attachment-name>...` — `--confirm/-y`.
|
|
||||||
|
|
||||||
## `tea times` (aliases: `time`, `t`)
|
|
||||||
Time tracking on issues/PRs. Fields: `id,created,repo,issue,user,duration`. Command-level: `--from/-f`, `--until/-u`, `--total/-t`, `--mine/-m`.
|
|
||||||
|
|
||||||
- `add, a <issue> <duration>` — e.g. `tea times add 1 1h25m`.
|
|
||||||
- `delete, rm <issue> <time-id>`
|
|
||||||
- `reset <issue>`
|
|
||||||
- `list, ls [username | #issue]` — username filters by user on the repo; `#N` filters by issue; `--mine` aggregates across all repos.
|
|
||||||
|
|
||||||
## `tea organizations` (aliases: `organization`, `org`)
|
|
||||||
- `list, ls`
|
|
||||||
- `create, c <name>` — `--full-name/-n`, `--description/-d`, `--website/-w`, `--location/-L`, `--visibility/-v`, `--repo-admins-can-change-team-access`.
|
|
||||||
- `delete, rm <name>`
|
|
||||||
|
|
||||||
## `tea repos` (alias: `repo`)
|
|
||||||
Fields: `description,forks,id,name,owner,stars,ssh,updated,url,permission,type`.
|
|
||||||
|
|
||||||
- `list, ls` — `--watched/-w`, `--starred/-s`, `--owner/-O`, `--type/-T {fork|mirror|source}`.
|
|
||||||
- `search, s [term]` — `--topic/-t`, `--type/-T`, `--owner/-O`, `--private {true|false}`, `--archived {true|false}`.
|
|
||||||
- `create, c` — `--name`, `--owner/-O`, `--private`, `--description/--desc`, `--init`, `--labels`, `--gitignores/--git`, `--license`, `--readme`, `--branch`, `--template`, `--trustmodel {committer|collaborator|collaborator+committer}`, `--object-format {sha1|sha256}`.
|
|
||||||
- `create-from-template, ct` — `--template/-t`, `--name/-n`, `--owner/-O`, `--private`, `--description/--desc`, copy toggles: `--content`, `--githooks`, `--avatar`, `--labels`, `--topics`, `--webhooks`.
|
|
||||||
- `fork, f` — `--owner/-O` (default: current user).
|
|
||||||
- `migrate, m` — `--name`, `--owner`, `--clone-url`, `--service {git|gitea|gitlab|gogs}`, `--mirror`, `--mirror-interval`, `--private`, `--template`, copy toggles: `--wiki`, `--issues`, `--labels`, `--pull-requests`, `--releases`, `--milestones`, `--lfs`, `--lfs-endpoint`, auth: `--auth-user`, `--auth-password`, `--auth-token`.
|
|
||||||
- `delete, rm` — `--name`, `--owner/-O`, `--force/-f`.
|
|
||||||
- `edit, e` — `--name`, `--description/--desc`, `--website`, `--private <bool>`, `--template <bool>`, `--archived <bool>`, `--default-branch`.
|
|
||||||
|
|
||||||
## `tea branches` (aliases: `branch`, `b`)
|
|
||||||
Fields: `name,protected,user-can-merge,user-can-push,protection`.
|
|
||||||
|
|
||||||
- `list, ls`
|
|
||||||
- `protect, P <branch>` — enable branch protection.
|
|
||||||
- `unprotect, U <branch>` — remove protection.
|
|
||||||
- `rename, rn <old> <new>`
|
|
||||||
|
|
||||||
## `tea actions` (alias: `action`)
|
|
||||||
CI management: secrets, variables, workflow definitions, workflow runs.
|
|
||||||
|
|
||||||
### `tea actions secrets` (alias: `secret`)
|
|
||||||
- `list, ls`
|
|
||||||
- `create, add, set <name> [value]` — `--file` or `--stdin` to read the value.
|
|
||||||
- `delete, remove, rm <name>` — `--confirm/-y`.
|
|
||||||
|
|
||||||
### `tea actions variables` (aliases: `variable`, `vars`, `var`)
|
|
||||||
- `list, ls` — `--name` to fetch a single variable.
|
|
||||||
- `set, create, update <name> [value]` — `--file`, `--stdin`.
|
|
||||||
- `delete, remove, rm <name>` — `--confirm/-y`.
|
|
||||||
|
|
||||||
### `tea actions runs` (alias: `run`)
|
|
||||||
- `list, ls` — `--status {success|failure|pending|queued|in_progress|skipped|canceled}`, `--branch`, `--event`, `--actor`, `--since`, `--until`.
|
|
||||||
- `view, show, get <run-id>` — `--jobs` prints the jobs table.
|
|
||||||
- `delete, remove, rm, cancel <run-id>` — `--confirm/-y`.
|
|
||||||
- `logs, log <run-id>` — `--job <id>`, `--follow/-f` (requires the job to be in progress).
|
|
||||||
|
|
||||||
### `tea actions workflows` (alias: `workflow`)
|
|
||||||
- `list, ls`
|
|
||||||
- `view, show, get <workflow-id>`
|
|
||||||
- `dispatch, trigger, run <workflow-id>` — `--ref/-r`, `--input/-i key=value` (repeatable), `--follow/-f`.
|
|
||||||
- `enable <workflow-id>`
|
|
||||||
- `disable <workflow-id>` — `--confirm/-y`.
|
|
||||||
|
|
||||||
## `tea webhooks` (aliases: `webhook`, `hooks`, `hook`)
|
|
||||||
Scope is selected by flag: `--repo`, `--org`, `--global`.
|
|
||||||
|
|
||||||
- `list, ls`
|
|
||||||
- `create, c <webhook-url>` — `--type {gitea|gogs|slack|discord|dingtalk|telegram|msteams|feishu|wechatwork|packagist}` (default: gitea), `--secret`, `--events` (default: push), `--active`, `--branch-filter`, `--authorization-header`.
|
|
||||||
- `update, edit, u <id>` — `--url`, `--secret`, `--events`, `--active` / `--inactive`, `--branch-filter`, `--authorization-header`.
|
|
||||||
- `delete, rm <id>` — `--confirm/-y`.
|
|
||||||
|
|
||||||
## `tea comment, c <issue/pr index> [body]`
|
|
||||||
Add a comment to an issue or PR. Body may be passed as an argument or supplied interactively.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# tea CLI — HELPERS
|
|
||||||
|
|
||||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
|
||||||
|
|
||||||
## `tea open, o`
|
|
||||||
Open the current repository/context in a web browser.
|
|
||||||
|
|
||||||
## `tea notifications` (aliases: `notification`, `n`)
|
|
||||||
Defaults to the current repo; `--mine/-m` aggregates across all your repos. Fields: `id,status,updated,index,type,state,title,repository`. Filters: `--types/-t {issue|pull|repository|commit}`, `--states/-s {pinned|unread|read}` (default: `unread,pinned`).
|
|
||||||
|
|
||||||
- `ls, list`
|
|
||||||
- `read, r [all | <id>]`
|
|
||||||
- `unread, u [all | <id>]`
|
|
||||||
- `pin, p [all | <id>]`
|
|
||||||
- `unpin [all | <id>]`
|
|
||||||
|
|
||||||
## `tea clone, C <repo-slug> [target-dir]`
|
|
||||||
Clone without requiring a local git install. Accepts slug forms: `gitea/tea`, `tea`, `gitea.com/gitea/tea`, `git@gitea.com:gitea/tea`, `https://gitea.com/gitea/tea`, `ssh://gitea.com:22/gitea/tea`. A host in the slug overrides `--login`. Options: `--depth/-d`, `--login/-l`.
|
|
||||||
|
|
||||||
## `tea api <endpoint>`
|
|
||||||
Authenticated HTTP request to the Gitea API. Endpoints are auto-prefixed with `/api/v1/` unless they start with `/api/` or `http(s)://`. Placeholders `{owner}` and `{repo}` are filled from the repo context.
|
|
||||||
|
|
||||||
- `--method/-X {GET|POST|PUT|PATCH|DELETE}` (default GET; switches to POST automatically when a body is provided)
|
|
||||||
- `--field/-f key=value` — string field on body (repeatable).
|
|
||||||
- `--Field/-F key=value` — typed field (numbers, booleans, null, JSON arrays/objects); `@file` or `@-` (stdin); `"null"` forces literal string.
|
|
||||||
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
|
|
||||||
- `--header/-H key:value` (repeatable)
|
|
||||||
- `--include/-i` — write status + response headers to stderr.
|
|
||||||
- `--output/-o <file>` — write response body to file (`-` = stdout). **Not the entity commands' format flag**: `-o json` here creates a file named `json` and prints nothing. The body is already JSON.
|
|
||||||
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# tea CLI — Index
|
|
||||||
|
|
||||||
Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Upstream: https://gitea.com/gitea/tea
|
|
||||||
|
|
||||||
`tea` is a productivity helper for Gitea. It uses the current git repository context (`$PWD`) — owner/repo/login are auto-detected when inside a repo. Config is persisted in `$XDG_CONFIG_HOME/tea`.
|
|
||||||
|
|
||||||
## Global options
|
|
||||||
|
|
||||||
- `--debug, --vvv` — enable debug mode
|
|
||||||
- `--help, -h`, `--version, -v`
|
|
||||||
|
|
||||||
## Common flags (present on nearly every command)
|
|
||||||
|
|
||||||
| Flag | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `--login, -l <name>` | use a specific login from the config |
|
|
||||||
| `--repo, -r <owner/repo>` | override repository context (local path or slug). **A slug only works where the command is pure API.** `pulls create`, `pulls checkout` and `pulls clean` need a real checkout and read this flag as a path — see [SKILL.md](../../SKILL.md) |
|
|
||||||
| `--remote, -R <name>` | discover login from this git remote |
|
|
||||||
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json`. **Entity commands only** — on `tea api` the same flag is a FILE NAME, see [HELPERS](./helpers.md) |
|
|
||||||
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
|
|
||||||
| `--fields, -f <list>` | which columns to print |
|
|
||||||
|
|
||||||
## Command categories
|
|
||||||
|
|
||||||
```
|
|
||||||
ENTITIES: issues, pulls, labels, milestones, releases, times,
|
|
||||||
organizations, repos, branches, actions, webhooks, comment
|
|
||||||
HELPERS: open, notifications, clone, api
|
|
||||||
MISC: whoami, admin
|
|
||||||
SETUP: logins, logout, ssh-keys
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ENTITIES](./entities.md)
|
|
||||||
- [HELPERS](./helpers.md)
|
|
||||||
- [MISC](./misc.md)
|
|
||||||
- [SETUP](./setup.md)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# tea CLI — MISCELLANEOUS
|
|
||||||
|
|
||||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
|
||||||
|
|
||||||
## `tea whoami`
|
|
||||||
Show the currently logged in user.
|
|
||||||
|
|
||||||
## `tea admin, a`
|
|
||||||
Operations requiring admin access on the Gitea instance.
|
|
||||||
|
|
||||||
### `tea admin users` (alias: `u`)
|
|
||||||
Fields: `id,login,full_name,email,avatar_url,language,is_admin,restricted,prohibit_login,location,website,description,visibility,activated,lastlogin_at,created_at`.
|
|
||||||
|
|
||||||
- `list, ls`
|
|
||||||
- `create, add, new` — `--username/-u`, `--password/-p` / `--password-file` / `--password-stdin`, `--email/-e`, `--full-name`, `--admin`, `--restricted`, `--prohibit-login`, `--no-must-change-password`, `--visibility {public|limited|private}`.
|
|
||||||
- `edit, update, e, u <username>` — paired flags: `--password` (or `--password-file`/`--password-stdin`), `--email/-e`, `--full-name`, `--description`, `--website`, `--location`, `--admin`/`--no-admin`, `--restricted`/`--no-restricted`, `--prohibit-login`/`--allow-login`, `--active`/`--inactive`, `--no-must-change-password`, `--visibility`, `--max-repo-creation` (-1 = unlimited), `--allow-git-hook`/`--no-allow-git-hook`, `--allow-import-local`/`--no-allow-import-local`, `--allow-create-organization`/`--no-allow-create-organization`.
|
|
||||||
- `delete, rm, remove <username>` — `--confirm/-y`.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# tea CLI — SETUP
|
|
||||||
|
|
||||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
|
||||||
|
|
||||||
## `tea logins` (alias: `login`)
|
|
||||||
- `list, ls`
|
|
||||||
- `add` — interactive when called without args. `--name/-n`, `--url/-u` (`$GITEA_SERVER_URL`), `--token/-t` (`$GITEA_SERVER_TOKEN`), `--user` (`$GITEA_SERVER_USER`), `--password/--pwd` (`$GITEA_SERVER_PASSWORD`), `--otp` (`$GITEA_SERVER_OTP`), `--scopes` (`$GITEA_SCOPES`), `--ssh-key/-s`, `--ssh-agent-principal/-c`, `--ssh-agent-key/-a`, `--insecure/-i`, `--no-version-check/--nv`, `--helper/-j`, `--oauth/-o` (plus `--client-id`, `--redirect-url`).
|
|
||||||
- `edit, e` — interactive.
|
|
||||||
- `delete, rm <name>`
|
|
||||||
- `default [<login>]` — get or set the default login.
|
|
||||||
- `oauth-refresh [<login>]` — refresh an OAuth token (opens browser if the refresh token is also expired).
|
|
||||||
|
|
||||||
## `tea logout <name>`
|
|
||||||
Remove a stored login.
|
|
||||||
|
|
||||||
## `tea ssh-keys` (alias: `ssh-key`)
|
|
||||||
- `list, ls`
|
|
||||||
- `add <key-file>` — `--title/-t` (defaults to filename without extension).
|
|
||||||
- `delete, rm <key-id>` — `--confirm/-y`.
|
|
||||||
Reference in New Issue
Block a user