Files
marketplace/cli/internal/cmd/evict_sync.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

230 lines
8.0 KiB
Go

package cmd
import (
"flag"
"fmt"
"os"
"sort"
"strings"
"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: "sync-evict",
Group: GroupSync,
Args: "[<id>…]",
Short: "refresh state from the tracker, then evict what is closed",
Long: `` + "`kettle evict`" + ` is the command that decides and deletes. This adds exactly one
thing in front of it: a ` + "`state:`" + ` that is not stale. A local ` + "`state:`" + ` is only as
fresh as the last pull, so an issue closed in the web UI an hour ago still reads
` + "`open`" + ` here and the offline command will — correctly — leave it alone. That is
the gap this closes, and before it existed the operator had to pull the five
closed issues back onto disk before anything could remove them.
ORDER OF OPERATIONS, AND IT IS THE WHOLE SAFETY ARGUMENT:
1. every candidate's state is fetched — ALL of them, before anything is
removed;
2. each answer must be the issue that was asked about, in a state the domain
recognizes;
3. only then is the eviction run, by handing the refreshed issues to the
domain — the same decision, the same deletion, the same protection of
` + "`origin: local`" + `, in one place.
A dead connection, a non-2xx, an answer about another issue, a state nobody
recognizes: the run stops at step 2 and NOTHING is deleted, not even the issues
whose answers had already arrived. That is stricter than push, which deletes as
it goes, and it costs nothing here — there is no ordering constraint between
evictions, so there is no reason to start before every answer is in.
A candidate is an issue carrying a ` + "`gitea:`" + ` handle. ` + "`origin: local`" + ` work has
none, is never asked about, and is never evicted — it is not in the tracker to
be closed. A tracked issue whose handle is missing or unreadable cannot be
verified, so it is reported and kept rather than guessed at.
Cost: one request per candidate. The store is a working set that push keeps
small, and a wrong answer here deletes a file, so each issue is asked about by
its own address rather than inferred from a list a limit could have truncated.
The refreshed state is written back even for the issues that stay: the answer is
already paid for, and a store that keeps a state the tracker has disowned is the
thing this command exists to fix.`,
Examples: []Example{
{"kettle sync-evict", "ask about every synced issue; evict the closed ones"},
{"kettle sync-evict old-thing another-thing", "only these"},
{"kettle sync-evict --dry-run", "ask, report, write and delete nothing"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
dryRun := fs.Bool("dry-run", false, "ask the tracker and report; write and delete nothing")
out := storeFlag(fs)
return func(args []string) error {
root, client, err := syncStartExisting(*out)
if err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
var missing []string
for _, id := range args {
if _, ok := issues[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
}
checkable, unverifiable, local := syncEvictCandidates(issues, args)
for _, s := range unverifiable {
fmt.Fprintf(os.Stderr, "warning: %s: %s — kept, and not asked about\n", s.id, s.why)
}
// A local issue is reported only when the operator named it: they
// asked about this file by name and are owed the reason it stayed.
if len(args) > 0 {
for _, s := range local {
fmt.Printf("%-11s %s %s\n", "kept", s.id, s.why)
}
}
if len(checkable) == 0 {
fmt.Println("nothing to check: nothing named carries a `gitea:` handle")
return nil
}
// Every answer first, deletions after.
fresh := make(map[string]string, len(checkable))
for _, c := range checkable {
got, err := client.For(c.key.Repo).GetIssue(c.key.Number)
if err != nil {
return Fail("%s: asking the tracker about %s failed: %v\nNothing was evicted.",
c.id, c.key, err)
}
state, ok := syncEvictConfirms(got, c.key.Number)
if !ok {
return Fail("%s: the answer for %s does not confirm a state "+
"(issue #%d, state %q). Nothing was evicted.",
c.id, c.key, got.Number, got.State)
}
fresh[c.id] = state
}
// The store stops lying even about the issues that stay. This is
// the only write made before the decision, and a dry run makes
// none.
changed := 0
for _, c := range checkable {
was := issues[c.id].State
if was == fresh[c.id] {
continue
}
fmt.Printf("%-11s %s %s -> %s\n", "state", c.id, was, fresh[c.id])
issues[c.id].State = fresh[c.id]
if *dryRun {
continue
}
if _, err := issue.Save(root, issues[c.id]); err != nil {
return err
}
changed++
}
ids := make([]string, 0, len(checkable))
for _, c := range checkable {
ids = append(ids, c.id)
}
rep, err := issue.Evict(root, issues, ids, *dryRun)
if err != nil {
return err
}
printEviction(rep, len(args) > 0)
// Evict rebuilds INDEX.md when something went; a state written back
// without an eviction changed the store too, and the index is a
// view of it. Neither happening means nothing changed on disk, and
// then nothing is rewritten.
if changed > 0 && rep.IndexPath == "" {
path, n, err := issue.BuildIndex(root)
if err != nil {
return err
}
fmt.Printf("index: %s — %d issue(s)\n", path, n)
}
return nil
}
},
})
}
// syncEvictTarget is one issue the tracker can be asked about, and the address
// to ask at — its own, so an issue that lives in another repository is asked
// about there.
type syncEvictTarget struct {
id string
key wire.Key
}
// syncEvictSkip is an issue that was not asked about, with the reason.
type syncEvictSkip struct{ id, why string }
// syncEvictCandidates splits the store into what the tracker can be asked
// about, what names a tracker but cannot be reached, and what is local.
//
// An unverifiable issue names a tracker but carries no handle to reach it by,
// which is a file to report and never one to delete on a guess. A local issue is
// in neither of those: it has no handle because it has never left this machine,
// and asking about it is not a question that has an answer.
//
// ids restricts the question to those issues; empty asks about the whole store.
func syncEvictCandidates(issues map[string]*issue.Issue, ids []string) (checkable []syncEvictTarget, unverifiable, local []syncEvictSkip) {
chosen := ids
if len(chosen) == 0 {
for id := range issues {
chosen = append(chosen, id)
}
sort.Strings(chosen)
}
for _, id := range chosen {
i, ok := issues[id]
if !ok {
continue
}
if i.IsLocal() {
local = append(local, syncEvictSkip{id, issue.LocalReason})
continue
}
key, ok := mapping.RemoteKeyOf(i)
if !ok {
unverifiable = append(unverifiable, syncEvictSkip{id,
fmt.Sprintf("origin: %s but no usable `%s:` handle", i.Origin, mapping.GiteaKey)})
continue
}
checkable = append(checkable, syncEvictTarget{id: id, key: key})
}
return checkable, unverifiable, local
}
// syncEvictConfirms is the state the tracker confirmed for this number — the
// deletion gate.
//
// Deliberately boring, and saying no by default, because everything downstream
// of a yes here may delete a file. An answer counts only when it is about the
// very issue that was asked about and names a state the domain recognizes. A
// non-2xx never reaches this: the transport has already returned an error.
func syncEvictConfirms(got *wire.Issue, number int) (string, bool) {
if got == nil || got.Number != number {
return "", false
}
for _, s := range issue.States {
if got.State == s {
return got.State, true
}
}
return "", false
}