Files
marketplace/cli/internal/cmd/close.go
T
naudachu 1239fdee70 refactor: move the transport onto the official Gitea SDK
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>
2026-08-11 19:29:38 +05:00

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
}