feat: add the kettle CLI, replacing the plugin's Python scripts
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "close",
|
||||
Group: GroupSync,
|
||||
Args: "<id|number> [<id|number>…]",
|
||||
Short: "close or reopen issues in the tracker, and on disk with them",
|
||||
Long: `STATE ONLY. This sends ` + "`{\"state\": …}`" + ` and nothing else: no title, no body, no
|
||||
labels, no milestone. Editing an issue is ` + "`kettle pull`" + ` -> edit ->
|
||||
` + "`kettle push --update`" + `; closing it is not an edit.
|
||||
|
||||
EXPLICIT IDS ONLY. No --milestone, no --label, no "close everything that looks
|
||||
done". Which issues are finished is a judgement about content; this carries that
|
||||
judgement out, one named id at a time. Nothing here deletes an issue either —
|
||||
the tracker can, and it is not an operation of this workflow.
|
||||
|
||||
WHAT MAY BE NAMED: a local slug, or a tracker key (42, #42, owner/repo#42, an
|
||||
issue URL). Both, and for the same reason: a push deletes the local file, so
|
||||
most issues in the tracker have no slug on disk to name them by. A slug is
|
||||
resolved through the file's ` + "`gitea:`" + ` handle when the file is there, and through
|
||||
the ledger (` + "`.remote.json`" + `) when push has already dropped it. A bare number is
|
||||
this project's repository; a qualified key names its own, so a foreign #42 can
|
||||
never be closed against the repository that happens to be configured here.
|
||||
|
||||
An ` + "`origin: local`" + ` issue cannot be closed. It is not in the tracker, so there is
|
||||
no state there to change, and the run stops naming the id rather than quietly
|
||||
editing one field of a local file. Push it first, or delete it.
|
||||
|
||||
THE LOCAL FILE IS WRITTEN ONLY AFTER THE TRACKER CONFIRMS: the answer has to be
|
||||
the very issue that was patched, in the state that was asked for. Anything else
|
||||
and the file is left exactly as it was. An issue whose local copy is gone
|
||||
(pushed and dropped) is closed in the tracker and nothing is written; the state
|
||||
comes down with the next pull.
|
||||
|
||||
A tracker that refuses to close an issue its own dependency graph still blocks
|
||||
says so in the answer, and the run stops with its words: close the blockers
|
||||
first, or unlink them.`,
|
||||
Examples: []Example{
|
||||
{"kettle close wire-sqlc-appclick", "one issue, by slug"},
|
||||
{"kettle close wire-sqlc-appclick 42 #43", "several, by slug or number"},
|
||||
{"kettle close --reopen 42", "the same thing backwards"},
|
||||
{"kettle close --dry-run 42 43", "what would change; no request at all"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
reopen := fs.Bool("reopen", false, "set the state back to open instead of closed")
|
||||
dryRun := fs.Bool("dry-run", false, "print what would change; makes no request")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return Fail("name at least one issue: a slug, or 42, #42, owner/repo#42, a URL")
|
||||
}
|
||||
state, verb, past := "closed", "close", "closed"
|
||||
if *reopen {
|
||||
state, verb, past = "open", "reopen", "reopened"
|
||||
}
|
||||
|
||||
root, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger := closeLedger(root)
|
||||
|
||||
// Every argument is resolved before anything is sent, so a typo in
|
||||
// the third id does not leave the first two closed.
|
||||
var targets []closeTarget
|
||||
for _, arg := range args {
|
||||
t, err := closeResolve(arg, root, issues, ledger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !closeHas(targets, t) {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
for _, t := range targets {
|
||||
where := "no local copy"
|
||||
if i, ok := issues[t.id]; ok {
|
||||
where = fmt.Sprintf("%s (state: %s)", issue.PathOf(root, t.id), i.State)
|
||||
}
|
||||
fmt.Printf("would %-6s %-24s %-20s %s\n",
|
||||
verb, closeName(t.id), t.key.In(client.Repo()), where)
|
||||
}
|
||||
fmt.Printf("%d issue(s) would be %s; no request was made\n", len(targets), past)
|
||||
return nil
|
||||
}
|
||||
|
||||
touched := 0
|
||||
for _, t := range targets {
|
||||
c := client
|
||||
if !t.key.Repo.Zero() {
|
||||
c = client.For(t.key.Repo)
|
||||
}
|
||||
req := wire.IssueRequest{State: wire.Set(state)}
|
||||
got, err := c.EditIssue(t.key.Number, req, fmt.Sprintf("state-%d", t.key.Number))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The gate. Above it nothing local has been written; below it the
|
||||
// file is about to say something the tracker had better agree
|
||||
// with. An answer counts only when it is the very issue that was
|
||||
// patched, in the state that was asked for.
|
||||
if got.Number != t.key.Number || got.State != state {
|
||||
return Fail("%s: the %s did not go through — the tracker answered for issue #%d in state %q. Nothing local was changed.",
|
||||
t.key.In(c.Repo()), verb, got.Number, got.State)
|
||||
}
|
||||
fmt.Printf("%-8s %-24s %-20s %s\n",
|
||||
past, closeName(t.id), t.key.In(c.Repo()), got.HTMLURL)
|
||||
|
||||
i, ok := issues[t.id]
|
||||
if !ok {
|
||||
fmt.Printf(" no local copy — `kettle pull %d` to get one\n", t.key.Number)
|
||||
continue
|
||||
}
|
||||
path, err := closeApply(root, i, state, got)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" state: %s %s\n", state, path)
|
||||
touched++
|
||||
}
|
||||
|
||||
// Only when a file actually changed: INDEX.md is a view of the
|
||||
// directory, and rewriting it after a run that wrote nothing local
|
||||
// is a write nobody asked for.
|
||||
if touched > 0 {
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// closeTarget is one issue a run will act on: where it is in the tracker, and
|
||||
// what this machine calls it, when this machine has a name for it at all.
|
||||
type closeTarget struct {
|
||||
// id is the local slug, "" when nothing here names this issue. Closing one
|
||||
// of those is ordinary — push deletes the file it would have been named by.
|
||||
id string
|
||||
// key is the tracker address. Its Repo is zero only when the argument was a
|
||||
// bare number and no local copy or ledger entry qualified it, which means
|
||||
// this project's own repository.
|
||||
key wire.Key
|
||||
}
|
||||
|
||||
// closeEntry is one row of the number -> slug ledger, parsed.
|
||||
type closeEntry struct {
|
||||
key wire.Key
|
||||
slug string
|
||||
}
|
||||
|
||||
// closeLedger is `.remote.json` as pairs, sorted so two identical runs report
|
||||
// an ambiguity in the same order.
|
||||
//
|
||||
// Read rather than ignored because it is the only thing on this machine that
|
||||
// still names an issue push has dropped: the file is gone, the slug is not.
|
||||
func closeLedger(root string) []closeEntry {
|
||||
var out []closeEntry
|
||||
for raw, slug := range gitea.LoadRemoteMap(root) {
|
||||
k, err := wire.ParseKey(raw)
|
||||
if err != nil || k.Repo.Zero() || k.Number < 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, closeEntry{key: k, slug: slug})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].key.String() < out[j].key.String() })
|
||||
return out
|
||||
}
|
||||
|
||||
// closeResolve turns one argument into a target.
|
||||
//
|
||||
// The order is the order of what is most authoritative about this machine: a
|
||||
// file on disk, then the ledger, then nothing. A key is already the tracker's
|
||||
// answer, so the only thing still wanted for it is the slug — so that the local
|
||||
// copy, if there is one, can be kept honest — and the file that carries the
|
||||
// handle knows that before the ledger does.
|
||||
//
|
||||
// The repository travels with the number, because a key may name one and a
|
||||
// `gitea:` handle always does. Sending a foreign key to whatever repository
|
||||
// this project points at would close somebody else's issue of the same number.
|
||||
func closeResolve(arg, root string, issues map[string]*issue.Issue, ledger []closeEntry) (closeTarget, error) {
|
||||
// A key first, and a slug never looks like one: slugs hold no `#`, no `/`
|
||||
// and no `:`, so the two vocabularies cannot collide.
|
||||
if k, err := wire.ParseKey(arg); err == nil {
|
||||
var hits []closeEntry
|
||||
for id, i := range issues {
|
||||
if h, ok := mapping.RemoteKeyOf(i); ok && h.Number == k.Number && (k.Repo.Zero() || h.Repo == k.Repo) {
|
||||
hits = append(hits, closeEntry{key: h, slug: id})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(a, b int) bool { return hits[a].slug < hits[b].slug })
|
||||
if len(hits) == 0 {
|
||||
for _, e := range ledger {
|
||||
if e.key.Number == k.Number && (k.Repo.Zero() || e.key.Repo == k.Repo) {
|
||||
hits = append(hits, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
hit, err := closeOne(hits, arg, "a slug")
|
||||
if err != nil {
|
||||
return closeTarget{}, err
|
||||
}
|
||||
t := closeTarget{key: k}
|
||||
if hit != nil {
|
||||
t.id = hit.slug
|
||||
t.key = k.In(hit.key.Repo)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
if i, ok := issues[arg]; ok {
|
||||
k, ok := mapping.RemoteKeyOf(i)
|
||||
if !ok {
|
||||
return closeTarget{}, Fail("%s is not in the tracker (origin: %s, no usable `%s:` handle) — "+
|
||||
"there is no state there to change; `kettle push %s` first",
|
||||
arg, i.Origin, mapping.GiteaKey, arg)
|
||||
}
|
||||
return closeTarget{id: arg, key: k}, nil
|
||||
}
|
||||
|
||||
var hits []closeEntry
|
||||
for _, e := range ledger {
|
||||
if e.slug == arg {
|
||||
hits = append(hits, e)
|
||||
}
|
||||
}
|
||||
hit, err := closeOne(hits, arg, "a number")
|
||||
if err != nil {
|
||||
return closeTarget{}, err
|
||||
}
|
||||
if hit != nil {
|
||||
return closeTarget{id: arg, key: hit.key}, nil // pushed, and its file went with the push
|
||||
}
|
||||
return closeTarget{}, Fail("no issue %q in %s or in its %s — name a tracker key "+
|
||||
"(42, #42, owner/repo#42, or the issue's URL) to close one this machine has never seen",
|
||||
arg, root, gitea.RemoteMapName)
|
||||
}
|
||||
|
||||
// closeOne is the single ledger row for an argument, nil when the ledger knows
|
||||
// nothing about it, or an error when it knows two.
|
||||
//
|
||||
// Two answers mean one number (or one slug) under more than one repository, and
|
||||
// only a qualified key can settle that. Guessing would close the wrong issue.
|
||||
func closeOne(hits []closeEntry, arg, what string) (*closeEntry, error) {
|
||||
seen := map[string]bool{}
|
||||
var uniq []closeEntry
|
||||
for _, h := range hits {
|
||||
if k := h.key.String() + " " + h.slug; !seen[k] {
|
||||
seen[k] = true
|
||||
uniq = append(uniq, h)
|
||||
}
|
||||
}
|
||||
switch len(uniq) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
return &uniq[0], nil
|
||||
}
|
||||
var where []string
|
||||
for _, h := range uniq {
|
||||
where = append(where, h.key.String())
|
||||
}
|
||||
return nil, Fail("%q matches %s under more than one repository (%s) — say which, as owner/repo#N",
|
||||
arg, what, strings.Join(where, ", "))
|
||||
}
|
||||
|
||||
// closeApply writes the confirmed state onto the local file and returns its
|
||||
// path.
|
||||
//
|
||||
// `state:` is the domain's own field, so it is set on the issue and written out
|
||||
// by the domain's own writer. The sync-owned freshness fields travel with it:
|
||||
// the answer that authorized this write is also the newest thing the tracker has
|
||||
// said about the issue, so `synced:` and `remote-updated:` are stamped from it
|
||||
// rather than left describing an older read.
|
||||
func closeApply(root string, i *issue.Issue, state string, got *wire.Issue) (string, error) {
|
||||
i.State = state
|
||||
if i.Extra == nil {
|
||||
i.Extra = map[string]string{}
|
||||
}
|
||||
i.Extra[mapping.SyncedKey] = time.Now().UTC().Format(time.RFC3339)
|
||||
if got.UpdatedAt != "" {
|
||||
i.Extra[mapping.RemoteUpdatedKey] = got.UpdatedAt
|
||||
}
|
||||
return issue.Save(root, i)
|
||||
}
|
||||
|
||||
func closeHas(targets []closeTarget, t closeTarget) bool {
|
||||
for _, have := range targets {
|
||||
if have == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// closeName is what a receipt calls an issue this machine has no name for.
|
||||
func closeName(id string) string {
|
||||
if id == "" {
|
||||
return "(no local copy)"
|
||||
}
|
||||
return id
|
||||
}
|
||||
Reference in New Issue
Block a user