1239fdee70
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
8.1 KiB
Go
232 lines
8.1 KiB
Go
package cmd
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
sdk "code.gitea.io/sdk/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: "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.Index, 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 *sdk.Issue, number int) (string, bool) {
|
|
if got == nil || int(got.Index) != number {
|
|
return "", false
|
|
}
|
|
for _, s := range issue.States {
|
|
if string(got.State) == s {
|
|
return s, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|