Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f18a633185 | |||
| e177f46510 |
@@ -7,7 +7,7 @@
|
||||
{
|
||||
"name": "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",
|
||||
|
||||
@@ -13,7 +13,7 @@ Where to go from here, and each of these directories documents itself:
|
||||
|
||||
| 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/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 |
|
||||
@@ -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
|
||||
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
|
||||
|
||||
**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 |
|
||||
|---|---|---|
|
||||
| [`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 |
|
||||
|
||||
---
|
||||
@@ -53,8 +53,9 @@ that answers `command not found: kettle` is telling you exactly this.
|
||||
# The `kettle` CLI
|
||||
|
||||
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
|
||||
with the network off.** Two direct dependencies, vendored, no cobra, seven
|
||||
them to and from Gitea when you say so — and, with `kettle api`, reaches every
|
||||
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
|
||||
[`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 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 —
|
||||
flags, defaults and worked examples. Flags may be typed after positional arguments.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.kettle/
|
||||
+23
-7
@@ -1,9 +1,23 @@
|
||||
# AGENTS.md — the kettle CLI
|
||||
|
||||
`kettle` is a globally installed binary. It owns everything mechanical about
|
||||
issues: what an issue is, where the store lives, who this machine is, and how
|
||||
issues move to and from Gitea. It replaced a set of Python scripts that used to
|
||||
ship inside the plugin.
|
||||
`kettle` is a globally installed binary. **It owns this project's connection to
|
||||
its tracker** — the credentials, the transport, the payload scratchpad — and
|
||||
issues are its main subject but no longer its only one: what an issue is, where
|
||||
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
|
||||
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
|
||||
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
|
||||
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`.
|
||||
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`,
|
||||
`config`, `gen`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
|
||||
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict` — plus `cmd/release`,
|
||||
against a fake Gitea.
|
||||
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict`, `api` — plus
|
||||
`cmd/release`, against a fake Gitea.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
`project`, `issue`, `sync`.
|
||||
`Group` and only shows up in the docs. Four groups, in presentation order:
|
||||
`project`, `issue`, `sync`, `api`.
|
||||
|
||||
| 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` |
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
`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
|
||||
**exactly as typed**: a relative `--out` stays relative to the working directory,
|
||||
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`.
|
||||
|
||||
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
|
||||
region marker out in full is a hard error — the generated block would end inside
|
||||
itself.
|
||||
@@ -180,8 +203,8 @@ itself.
|
||||
three conventions, the round trip, the generator. The file table names every
|
||||
source file in this directory.
|
||||
- **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
|
||||
push/pull ordering guarantees change.
|
||||
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, a command
|
||||
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>`
|
||||
and the generated SKILL.md blocks are the two places that exist for it, and a
|
||||
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
|
||||
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{
|
||||
{"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"},
|
||||
{"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"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
name := fs.String("name", "", "login name (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)")
|
||||
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)")
|
||||
|
||||
return func(args []string) error {
|
||||
@@ -61,7 +72,14 @@ argument is in the shell history the moment it is typed:
|
||||
if 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
|
||||
|
||||
@@ -82,6 +100,7 @@ argument is in the shell history the moment it is typed:
|
||||
Name: *name,
|
||||
URL: strings.TrimRight(*url, "/"),
|
||||
User: *user,
|
||||
Scopes: splitScopes(*scopes),
|
||||
Token: secret,
|
||||
}
|
||||
if existing := logins.Find(*name); existing != nil {
|
||||
@@ -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.
|
||||
//
|
||||
// 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
|
||||
// message has to say which file was read and what it holds.
|
||||
func TestAMissingLoginIsExplained(t *testing.T) {
|
||||
|
||||
@@ -26,14 +26,16 @@ const (
|
||||
GroupProject = "project"
|
||||
GroupIssue = "issue"
|
||||
GroupSync = "sync"
|
||||
GroupAPI = "api"
|
||||
)
|
||||
|
||||
var groupOrder = []string{GroupProject, GroupIssue, GroupSync}
|
||||
var groupOrder = []string{GroupProject, GroupIssue, GroupSync, GroupAPI}
|
||||
|
||||
var groupBlurb = map[string]string{
|
||||
GroupProject: "the project itself",
|
||||
GroupIssue: "issues as units of work — offline, no tracker involved",
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"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("url %s\n", orNone(red.URL))
|
||||
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 != "" {
|
||||
fmt.Printf("repo %s\n", r.Slug())
|
||||
} else {
|
||||
|
||||
@@ -13,9 +13,19 @@ the tree that imports yaml.
|
||||
<project>/.kettle/config.yaml login: noodles a NAME, never a token
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
A request, a store path, an issue. This package reads and writes two files and
|
||||
|
||||
@@ -60,6 +60,14 @@ type Login struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -216,6 +224,11 @@ type Resolved struct {
|
||||
Token string
|
||||
Owner 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.
|
||||
@@ -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`",
|
||||
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 != "" {
|
||||
|
||||
@@ -11,11 +11,11 @@ package, not beside it. `TestTransportDoesNotImportTheDomain` is the check.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `client.go` | `New`, `For`, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail`, `ListIssues` and its budget |
|
||||
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, dependencies |
|
||||
| `client.go` | `New`, `For`, `Do` and the endpoint rule, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail` |
|
||||
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, `ListIssues` and its budget, dependencies |
|
||||
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
|
||||
| `remotemap.go` | `RemoteMap` — the number → slug ledger, and why nothing prunes it |
|
||||
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate |
|
||||
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate, `Do` and what it refuses |
|
||||
| `remotemap_test.go` | load, merge, save |
|
||||
|
||||
## What this package is, now that the SDK exists
|
||||
@@ -102,6 +102,46 @@ somebody types when they want to see what was in it. `ListLabels` is read from t
|
||||
repository and never from a cache — a cache answers "what did we create last time",
|
||||
and the question is "what does this repository have right now".
|
||||
|
||||
## `Do` — the requests the SDK cannot express
|
||||
|
||||
`Do(method, path, body, name)` sends one request and returns the status and the
|
||||
body exactly as they came back. It was here before it was general: the dependency
|
||||
endpoint takes a body the SDK's own `IssueMeta` cannot spell, so a hand-rolled
|
||||
request already existed and `post` is now three lines on top of this one.
|
||||
|
||||
Exporting it is what lets [`kettle api`](../cmd/AGENTS.md) reach a release, a pull
|
||||
request or a webhook **without this package growing a method per entity** and
|
||||
without a second client holding the credentials all over again. It goes through
|
||||
the same `http.Client`, so it gets the same three services as everything else: the
|
||||
body is filed by the same `RoundTripper`, the same `token …` header authenticates
|
||||
it, and a non-2xx is the same `*APIError`.
|
||||
|
||||
Three things it deliberately does not do, and each of them is a way of not lying:
|
||||
|
||||
- **it does not paginate** — one call is one HTTP request, and `?page=`/`?limit=`
|
||||
are the caller's. The pagination below exists for a listing with a budget to
|
||||
spend; a passthrough that stitched pages together would report as one answer
|
||||
something that was several;
|
||||
- **it does not parse or reformat the answer** — bytes in, bytes out;
|
||||
- **it does not know what an issue is** — nothing about it names a domain concept,
|
||||
so `TestTransportDoesNotImportTheDomain` needed no change. A generic transport is
|
||||
no more a domain than a specific one was.
|
||||
|
||||
**The endpoint rule** is `tea api`'s, so a table of endpoints written for that tool
|
||||
still works: a bare path is under `/api/v1/`, a path starting `/api/` is taken as
|
||||
it stands (that is how anything outside v1 is reached), and a full URL is a full
|
||||
URL — **but only on this instance**. That last clause is the one place this is
|
||||
stricter than the tool it replaces, and it is not fussiness: every request carries
|
||||
the project's token in a header, so a URL on another host would hand the token to
|
||||
whatever was typed. Another instance is `KETTLE_URL`.
|
||||
|
||||
**A 403 answers with what to do about it.** Gitea scopes a token as
|
||||
`<read|write>:<category>` and names no scope in the refusal, so `APIError.Error`
|
||||
appends the one sentence that helps — everything outside issues is `repository`,
|
||||
and `kettle auth list` shows what each login recorded. It does not guess a
|
||||
specific scope, because the server did not say and a wrong guess is worse than
|
||||
none.
|
||||
|
||||
## The ledger
|
||||
|
||||
`.remote.json`, **inside the issue store, beside the issues it indexes** — it is
|
||||
@@ -142,9 +182,10 @@ blocking panel and refuse to close a blocked issue first.
|
||||
- **Writing does not.** Gitea's own `IssueMeta` is `{index, owner, repo}` and has
|
||||
been since the endpoint existed; the SDK's is `{index}`, which can only link
|
||||
inside one repository, and a `depends:` entry is allowed to live somewhere else.
|
||||
So one hand-rolled `POST` survives, through the same HTTP client as everything
|
||||
else — same payload dump, same `*APIError`. The URL names the blocked issue and
|
||||
the body the blocker, which is the direction `Dependencies` reads back.
|
||||
So it goes out through `Do` — same HTTP client, same payload dump, same
|
||||
`*APIError`. This was the request `Do` was written for, back when it was the only
|
||||
one. The URL names the blocked issue and the body the blocker, which is the
|
||||
direction `Dependencies` reads back.
|
||||
- **The version gates both.** The routes are absent from Gitea 1.19 and present in
|
||||
1.20, checked against the release tags themselves, so an older instance is
|
||||
answered from the version it already gave us rather than from a 404 — which on an
|
||||
|
||||
+115
-21
@@ -69,8 +69,9 @@ const (
|
||||
type Client struct {
|
||||
// api is the SDK client: one per run, shared by every copy For makes.
|
||||
api *sdk.Client
|
||||
// http is the SDK's transport, kept because AddDependency still sends one
|
||||
// request by hand — see there.
|
||||
// http is the SDK's transport, kept because Do sends requests by hand — the
|
||||
// dependency endpoint the SDK cannot spell, and every endpoint this package
|
||||
// has no method for.
|
||||
http *http.Client
|
||||
// dump is the RoundTripper that files request bodies. Shared with every
|
||||
// copy For makes, because the scratchpad is one directory per run.
|
||||
@@ -186,8 +187,30 @@ func (e *APIError) Error() string {
|
||||
if 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
|
||||
// handful of places where one code means something specific — a 409 from a
|
||||
@@ -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
|
||||
// whatever comes back.
|
||||
// Do sends one request to a path under this instance's API and returns the
|
||||
// status and the body exactly as they came back.
|
||||
//
|
||||
// It exists for AddDependency and for nothing else — see there for what the SDK
|
||||
// leaves out. It goes through the same http.Client, so the body is filed and a
|
||||
// failure carries the status and the server's words exactly as every other call
|
||||
// in this package does.
|
||||
func (c *Client) post(path string, body any, name string) error {
|
||||
raw, err := json.Marshal(body)
|
||||
// It is the escape hatch, and it was here before it was one: the dependency
|
||||
// endpoint needed a body the SDK's own type cannot spell (see AddDependency),
|
||||
// so a hand-rolled request already existed. What has changed is that it is
|
||||
// exported, which is what lets `kettle api` reach a release, a pull request or a
|
||||
// webhook without this package growing a method per entity — and without a
|
||||
// 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 {
|
||||
return err
|
||||
return 0, nil, err
|
||||
}
|
||||
endpoint := c.base + "/api/v1/" + strings.TrimLeft(path, "/")
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, nil, err
|
||||
}
|
||||
// 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.
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
@@ -436,14 +487,57 @@ func (c *Client) post(path string, body any, name string) error {
|
||||
if err != nil {
|
||||
// The token travels in a header and never in the URL, so an error is
|
||||
// free to quote the URL in full.
|
||||
return err
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
answer, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return &APIError{Method: http.MethodPost, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
||||
answer, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
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,
|
||||
// because a 401 names nothing an operator can act on — and before the client is
|
||||
// built at all, because building one dials.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "3.0.0",
|
||||
"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.1.0",
|
||||
"author": {
|
||||
"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
|
||||
for anybody building this. There is no `vendor/`: a build resolves its modules from
|
||||
the module cache or the network, and `go.sum` is what makes that safe. Why that
|
||||
trade was taken is in [`cli/AGENTS.md`](../../cli/AGENTS.md).
|
||||
for anybody building this. `vendor/` is committed, so what compiles is what is in
|
||||
this repository's history; what that does and does not buy is in
|
||||
[`cli/AGENTS.md`](../../cli/AGENTS.md).
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
**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`,
|
||||
`auth`, `project`, `issue`, `sync`, `use`. The generator writes one
|
||||
groups its commands `project`, `issue`, `sync`, `api`; the plugin's skills are
|
||||
`init`, `auth`, `project`, `issue`, `sync`, `api`. The generator writes one
|
||||
`<group>/SKILL.md`, so:
|
||||
|
||||
| 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 |
|
||||
| `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 |
|
||||
| `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
|
||||
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
|
||||
@@ -102,9 +107,10 @@ skills/
|
||||
issue/ SKILL.md /kettle:issue — the offline commands
|
||||
references/format.md THE canonical issue format; source of truth
|
||||
sync/ SKILL.md /kettle:sync — the tracker commands
|
||||
use/ SKILL.md /kettle:use — the `tea` CLI, for the Gitea entities
|
||||
references/tea/ kettle does not cover: releases, webhooks,
|
||||
actions, pull requests
|
||||
api/ SKILL.md /kettle:api — every Gitea entity with no command of
|
||||
its own: pull requests, releases, tags, milestones,
|
||||
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
|
||||
@@ -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 |
|
||||
| `.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 |
|
||||
| `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
|
||||
`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: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: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 |
|
||||
|
||||
## Prerequisites
|
||||
@@ -27,17 +27,18 @@ the tool.
|
||||
- **Claude Code** — CLI, desktop app, or IDE extension.
|
||||
- **The `kettle` binary, on your `PATH`.** It is not installed for you, and
|
||||
nothing here works without it — see below.
|
||||
- **Python 3** — the two `agents-*` hooks are Python scripts; `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).
|
||||
It keeps its own logins (`tea logins add`), separate from kettle's.
|
||||
- **Python 3** — the `agents-sync` hook is a Python script despite its `.sh`
|
||||
name; `python3` must be on `$PATH`. Nothing else here needs it.
|
||||
|
||||
There is **no second CLI to install** — `kettle api`
|
||||
reaches every Gitea entity this plugin has no command for, under the login the
|
||||
project already pins.
|
||||
|
||||
### Installing the binary
|
||||
|
||||
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
|
||||
modules, verified against `go.sum`.
|
||||
requires it. `vendor/` is committed, so a build compiles exactly what is in this
|
||||
repository's history.
|
||||
|
||||
```bash
|
||||
# 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.
|
||||
|
||||
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
|
||||
dependency come back as a question, not as a guess.
|
||||
set it was handed, no request it composed itself — `kettle api` goes out as the
|
||||
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
|
||||
|
||||
@@ -169,8 +172,7 @@ skills/
|
||||
issue/ /kettle:issue — the issue domain, offline
|
||||
references/format.md canonical issue format (identity, types, templates)
|
||||
sync/ /kettle:sync — the bridge to Gitea
|
||||
use/ /kettle:use — tea CLI reference
|
||||
references/tea/ command docs
|
||||
api/ /kettle:api — every other Gitea entity, by endpoint
|
||||
```
|
||||
|
||||
`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`
|
||||
- `/kettle:issue` — `new`, `check`, `ac`, `tree`, `index`, `evict`
|
||||
- `/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
|
||||
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
|
||||
|
||||
1. **`kettle` only.** No raw `tea`, no `tea api`, no curl at a tracker. The
|
||||
binary carries the project's credentials; there is no login for you to name
|
||||
and none for you to choose. If a task needs an entity `kettle` does not cover,
|
||||
that is a finding for the caller, not a command for you to improvise.
|
||||
1. **`kettle` only.** No curl at a tracker, no other CLI, no request you composed
|
||||
yourself. The binary carries the project's credentials; there is no login for
|
||||
you to name and none for you to choose. `kettle api` is a kettle command and
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| `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
|
||||
under `$XDG_CONFIG_HOME/tea` and its own logins (`tea logins list`), and
|
||||
configuring one tool configures nothing in the other — see `/kettle:use`.
|
||||
## Scopes: what the token is allowed to do
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
**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
|
||||
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||
**go 1.26**), or `go install
|
||||
|
||||
@@ -43,10 +43,10 @@ milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
gitea: claude-skills/marketplace#42
|
||||
remote-updated: 2026-08-09T18:24:01Z
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
marketplace repository (go.mod requires **go 1.26**), or
|
||||
`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.
|
||||
|
||||
--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 |
|
||||
| --- | --- | --- |
|
||||
| `--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) |
|
||||
| `--url` | — | instance URL, e.g. https://git.example.com (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
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ second-guesses it. Knowledge flows one way: delete the tracker from the world an
|
||||
the issue domain does not notice.
|
||||
|
||||
**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
|
||||
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||
**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
|
||||
|
||||
`tea issues 42 -o json` and `tea api …/issues/42` put the whole payload —
|
||||
avatars, nested user objects, every comment body — into your context whether you
|
||||
need it or not. `kettle pull` writes flat markdown and prints a compact line per
|
||||
issue; `kettle remote` lists the tracker without writing anything at all. Use
|
||||
those.
|
||||
`kettle api repos/{owner}/{repo}/issues/42` will answer, and answering is the
|
||||
problem: the whole payload — avatars, nested user objects, every comment body —
|
||||
lands in your context whether you need it or not. `kettle pull` writes flat
|
||||
markdown and prints a compact line per issue; `kettle remote` lists the tracker
|
||||
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
|
||||
|
||||
@@ -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 —
|
||||
deleting it costs nothing. A run that sends nothing leaves no directory behind.
|
||||
|
||||
For Gitea entities `kettle` does not cover — releases, webhooks, actions, pull
|
||||
requests — the tool is `tea`, and it keeps its own configuration and its own
|
||||
logins. `/kettle:use`.
|
||||
For Gitea entities `kettle` has no command for — releases, webhooks, actions,
|
||||
pull requests — `kettle api` sends the request under this same login, into this
|
||||
same scratchpad. `/kettle:api`.
|
||||
|
||||
The commands themselves follow. Their usage lines, flags, defaults and examples
|
||||
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