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: "[…]", 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 }