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>
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package cmd
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"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)
|
|
}
|
|
// STATE AND NOTHING ELSE. Every other field of an edit is
|
|
// left at its zero value, which the SDK sends as null and
|
|
// Gitea reads as "no opinion" — bar the title, which goes
|
|
// as the empty string Gitea reads the same way. Building
|
|
// this by hand rather than through mapping.ToEdit is the
|
|
// point: a translated issue would carry its body.
|
|
want := sdk.StateType(state)
|
|
req := sdk.EditIssueOption{State: &want}
|
|
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 int(got.Index) != t.key.Number || got.State != want {
|
|
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.Index, 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 *sdk.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 stamp := mapping.Stamp(got.Updated); stamp != "" {
|
|
i.Extra[mapping.RemoteUpdatedKey] = stamp
|
|
}
|
|
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
|
|
}
|