9480e48312
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>
328 lines
11 KiB
Go
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
|
|
}
|