feat: reach the rest of Gitea with kettle api, and drop tea
The plugin required `tea`, Gitea's own CLI, for everything that is not an issue: releases, pull requests, milestones, branches, actions, webhooks. That put a second binary, a second set of logins nothing here could see, and 400 lines documenting somebody else's flags outside anything this repository can test. One command over the transport that already existed removes all three. Transport: `post` — the hand-rolled request the SDK cannot express, written for the dependency endpoint — is generalized to an exported `Do`, and `post` is three lines on top of it. Same http.Client, so the same RoundTripper files the body under .kettle/payload/, the same `token …` header authenticates it, and a non-2xx is the same *APIError. It does not paginate, does not reformat the answer, and names no domain concept, so the layering test is untouched. The endpoint rule is `tea api`'s, so an endpoint table written for that tool still works — with one restriction it did not have: a full URL must be on this instance. Every request carries the project's token in a header, and a URL on another host would hand the token to whatever was typed. Command: `kettle api <endpoint>` in a new `api` group, so the generator writes plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one word. No --repo and no --login, for the reason no sync command has them: a cross-repository address is an address, and another instance is KETTLE_URL. `-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision. Scopes: a token minted for issues carries write:issue and answers 403 on the first request outside issues, naming no scope. Gitea cannot be asked what a token may do — its own token listing needs a password — so `auth add --scopes` records it, `auth list` and `config` show it, and a 403 says which category it is likely to be. Documentation only; nothing is checked against it. skills/use — the tea reference, 239 lines of it — becomes skills/api: what to ask for, which endpoints paginate, and how to write a body. Every mention of `tea` as a requirement is gone from the manifests, the READMEs, the runner and the four other skills; what survives is the back-compat with the old plugin, which is a decision and not a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user