Files
marketplace/cli/internal/cmd/close.go
T
naudachu 9480e48312 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>
2026-08-11 19:05:39 +05:00

328 lines
11 KiB
Go

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
}