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

595 lines
20 KiB
Go

package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"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: "pull",
Group: GroupSync,
Args: "[<key>…]",
Short: "fetch issues from the tracker into the local store",
Long: `THIS IS HOW A PUSHED ISSUE COMES BACK. ` + "`kettle push`" + ` deletes the local file the
moment the tracker confirms the write, so a pull is not a refresh of a copy you
kept — it is how the copy comes to exist at all.
It lands under the SAME slug it had before, after a rename in the web UI and on
a machine that has never seen the issue. Three sources answer "what is this
issue called here", in this order:
.remote.json the number -> slug ledger; the only one that knows what
is on disk right now, so it wins
<!-- kettle:id … --> the marker in the tracker-side body; it survives a lost
ledger, a fresh clone, another machine, and a retitling
the title slugified — where an issue filed in the web UI gets its
first local name
A marker is taken at its word only when the slug is free; a name already in use
is a collision, not an identity, and is uniquified rather than allowed to
overwrite somebody else's issue. The marker itself is stripped out of what lands
on disk.
TWO WAYS TO NAME WHAT TO PULL, and they are not the same operation:
kettle pull 42 #43 owner/repo#44 by key — an ADDRESS
kettle pull --milestone v0.2 by filter — a QUERY
A key fetches an issue in ANY state, because a number is an address and not a
question about state. Only filter mode leaves closed issues out — a closed issue
is not a unit of work — and only ` + "`--state closed`" + ` puts one in the store. An issue
already on disk is refreshed either way, so a local copy learns it was closed
instead of staying open forever, and the count that stayed out goes to stderr.
` + "`--limit`" + ` IS ON THE WRITE, NOT ON THE SELECTION. It counts the issues this run puts
in the store and never the closed ones it enumerated and threw away, so pages
keep coming until the budget is full — and stop the moment it is. A filter that
matches almost only closed issues ends in a warning and a short answer rather
than a walk of the whole tracker.
A PULL RETURNS THE UNIT OF WORK, NOT ONE ROW OF IT. ` + "`depends:`" + ` is filled from the
tracker's own dependency graph and every blocker comes down with it, recursively,
to --depth. What that costs, stated rather than hidden: one request per issue
that lands in the store, plus one per blocker the selection did not already
carry. ` + "`--no-deps`" + ` is the way back to one request, and narrows the answer to the
one issue you asked for. Dependencies are outside --limit: a blocker is followed
because a stored issue named it, not because the filter selected it, so a
filtered pull can leave more files behind than its limit — including one from
another milestone. The one blocker that does not land is a closed one.
PULLING OVERWRITES THE BODY: a fetch, not a merge. Local edits you have not
pushed are lost, with exactly one exception — checkbox state. A tick is monotone,
so a ` + "`[x]`" + ` on either side wins for any item whose text matches; unticking is not,
so untick locally and push. ` + "`--cached`" + ` skips an issue before any of that.
Comments ride along: the thread lands beside the issue in <id>.comments.md. It
costs no request when the payload says there are none, and a file left over from
an earlier pull is deleted — so no file means "no comments", never "not asked
for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
Examples: []Example{
{"kettle pull 42", "the issue and everything blocking it, in any state"},
{"kettle pull 42 --no-deps", "just that one issue — one request"},
{"kettle pull owner/repo#42", "an issue in another repository"},
{"kettle pull --milestone v0.2 --limit 20", "20 open issues from a milestone, blockers included"},
{"kettle pull --label type/bug --state all", "every bug; the closed ones are enumerated, not stored"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
milestone := fs.String("milestone", "", "pull a whole milestone (id or title)")
var labels stringList
fs.Var(&labels, "label", "filter by label; repeat for AND")
// Both spellings, because both are what somebody has in hand: `-q` is
// what a person types and `--query` is what a script reads back.
var query string
fs.StringVar(&query, "q", "", "search text in title and body")
fs.StringVar(&query, "query", "", "the long spelling of -q")
state := fs.String("state", "open", "filter mode only: open, closed or all")
limit := fs.Int("limit", 100, "filter mode: how many issues to STORE, not to enumerate")
noDeps := fs.Bool("no-deps", false, "do not fill depends: and do not follow blockers")
depth := fs.Int("depth", 3, "how deep to follow blockers")
cached := fs.Bool("cached", false, "skip issues already on disk instead of refetching")
out := storeFlag(fs)
return func(args []string) error {
filtered := *milestone != "" || len(labels) > 0 || query != ""
switch {
case len(args) > 0 && filtered:
return Fail("pass issue keys OR filters, not both")
case len(args) == 0 && !filtered:
return Fail("nothing to pull: pass an issue key, or --milestone / --label / -q")
case !contains([]string{"open", "closed", "all"}, *state):
return Fail("--state %q must be open, closed or all", *state)
case *limit < 1:
return Fail("--limit must be 1 or more, got %d", *limit)
case *depth < 0:
return Fail("--depth must be 0 or more, got %d", *depth)
}
// Keys are parsed before anything is opened: a typo in a key is
// not a network problem and must not be reported as one.
keys, named, err := pullKeys(args)
if err != nil {
return err
}
root, client, err := syncStart(*out)
if err != nil {
return err
}
// A key may name its own repository; the project's is the
// fallback, never an override.
if !named.Zero() {
client = client.For(named)
}
repo := client.Repo()
// A first pull into a fresh checkout has to create the store, and
// it says so — with an absolute path, so it cannot be a stray
// working directory.
created, err := issue.CreateStore(root)
if err != nil {
return err
}
if created {
abs, _ := filepath.Abs(root)
fmt.Fprintf(os.Stderr, "created store %s\n", abs)
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
ledger := loadLedgerOrFold(root, issues)
namer := &pullNamer{root: root, repo: repo, ledger: ledger, taken: map[string]bool{}}
for id := range issues {
namer.taken[id] = true
}
// What the ledger already knows, so a `#N` in a body resolves to
// a slug this run never fetched.
numberOf := map[int]string{}
for raw, slug := range ledger {
if k, err := wire.ParseKey(raw); err == nil && k.Repo == repo {
numberOf[k.Number] = slug
}
}
// A closed issue is not a unit of work: filter mode enumerates it
// and keeps it out of the store unless the operator named the
// state. A key is an address, not a bulk read, so key mode is
// exempt.
dropClosed := filtered && *state != "closed"
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
State: *state, Labels: labels, Query: query, Milestone: *milestone,
Limit: *limit,
Keep: func(p *wire.Issue) bool {
return pullLandsInStore(p, dropClosed, namer)
},
})
if err != nil {
return err
}
var written, skipped []string
var dropped []int
type unresolved struct {
id string
numbers []int
}
var pending []unresolved
seen := map[int]bool{}
for _, t := range queue {
seen[t.payload.Number] = true
}
for len(queue) > 0 {
task := queue[0]
queue = queue[1:]
p := task.payload
id, err := namer.idFor(p)
if err != nil {
return err
}
stored := pullStored(root, id)
// Closed and not already ours: nothing is written and nothing
// is asked of the server for it — not its comments, not its
// links, and its blockers are not followed. The slug stays
// unclaimed too, so no other issue ends up pointing
// `depends:` at a file that is not there.
if dropClosed && p.State == "closed" && !stored {
dropped = append(dropped, p.Number)
continue
}
namer.taken[id] = true
numberOf[p.Number] = id
// The native links, fetched ONCE for the two things they are
// for: filling this issue's `depends:` and telling the walk
// where to go next. One request per issue that lands in the
// store, and only one.
var blockers []int
if !*noDeps {
if blockers, err = pullBlockers(client, p.Number, repo); err != nil {
return err
}
}
if *cached && stored {
skipped = append(skipped, id) // body and thread unread; only the links cost
} else {
// The copy already on disk, as it was when this run
// started. It contributes its ticked checkboxes and
// nothing else.
local := ""
if prev := issues[id]; prev != nil {
local = prev.Body
}
next, missing := mapping.FromPayload(p, id, repo, mapping.PayloadOptions{
IDForNumber: numberOf,
ExtraNumbers: blockers,
// The clock is the caller's: mapping is a pure layer
// and a package with a clock in it is not one.
Synced: time.Now().UTC().Format(time.RFC3339),
LocalBody: local,
})
if _, err := issue.Save(root, next); err != nil {
return err
}
if _, err := pullSyncComments(client, root, id, p.Number, p.Comments); err != nil {
return err
}
ledger.Set(wire.Key{Repo: repo, Number: p.Number}, id)
written = append(written, id)
pending = append(pending, unresolved{id, missing})
}
if *noDeps || task.depth >= *depth {
continue
}
for _, n := range append(mapping.NumbersInBody(p.Body), blockers...) {
if seen[n] {
continue
}
seen[n] = true
child, err := client.GetIssue(n)
if err != nil {
return err
}
queue = append(queue, pullTask{payload: child, depth: task.depth + 1})
}
}
// Nothing is dropped in silence.
if len(dropped) > 0 {
fmt.Fprintf(os.Stderr, "%d closed issue(s) enumerated, not stored"+
" (--state closed to pull them)\n", len(dropped))
}
// Second pass: a `#N` that named an issue this run had not written
// yet. The first write could not resolve it to a slug; by now the
// file it names is on disk.
for _, u := range pending {
var newly []string
for _, n := range u.numbers {
if slug := numberOf[n]; slug != "" && slug != u.id {
newly = append(newly, slug)
}
}
if len(newly) == 0 {
continue
}
i, err := issue.Load(root, u.id)
if err != nil {
return err
}
for _, slug := range newly {
if !contains(i.Depends, slug) {
i.Depends = append(i.Depends, slug)
}
}
if _, err := issue.Save(root, i); err != nil {
return err
}
}
if err := ledger.Save(root); err != nil {
return err
}
indexPath, _, err := issue.BuildIndex(root)
if err != nil {
return err
}
return pullReceipt(root, written, skipped, indexPath)
}
},
})
}
// pullTask is one issue to walk, and how far from a seed it was found.
type pullTask struct {
payload *wire.Issue
depth int
}
// pullKeys parses the positional arguments and the one repository they may name.
//
// All of them or none: a run addresses one repository, because the client, the
// ledger keys and the `gitea:` field all have to agree about which one.
func pullKeys(args []string) ([]wire.Key, wire.Repo, error) {
var keys []wire.Key
var named wire.Repo
for _, a := range args {
k, err := wire.ParseKey(a)
if err != nil {
return nil, wire.Repo{}, err
}
if !k.Repo.Zero() {
if !named.Zero() && named != k.Repo {
return nil, wire.Repo{}, Fail("all keys must name one repository, got %s and %s", named, k.Repo)
}
named = k.Repo
}
keys = append(keys, k)
}
return keys, named, nil
}
// pullSeed is what the walk starts from: the issues a key addresses, or the ones
// a filter selected.
func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilter) ([]pullTask, error) {
if !filtered {
out := make([]pullTask, 0, len(keys))
for _, k := range keys {
p, err := c.GetIssue(k.Number)
if err != nil {
return nil, err
}
out = append(out, pullTask{payload: p})
}
return out, nil
}
listing, err := c.ListIssues(f)
if err != nil {
return nil, err
}
if len(listing.Issues) == 0 {
return nil, Fail("no issues match that filter")
}
if listing.Warning != "" {
fmt.Fprintf(os.Stderr, "warning: %s\n", listing.Warning)
}
var what []string
if listing.Milestone != "" {
what = append(what, "milestone "+listing.Milestone)
}
for _, l := range f.Labels {
what = append(what, "label "+l)
}
if f.Query != "" {
what = append(what, fmt.Sprintf("q=%q", f.Query))
}
fmt.Fprintf(os.Stderr, "%d issue(s) match %s (%s)\n",
len(listing.Issues), strings.Join(what, " + "), f.State)
out := make([]pullTask, 0, len(listing.Issues))
for i := range listing.Issues {
out = append(out, pullTask{payload: &listing.Issues[i]})
}
return out, nil
}
// pullLandsInStore is the --limit predicate: would this payload leave a file in
// the store?
//
// It has to be the same test the walk applies, or the budget is spent on issues
// that never land — which is the bug it exists to prevent. A closed issue counts
// only when the store already has it (it is refreshed, and that is a write);
// anything else counts, including one --cached will skip, because a skipped
// issue is still an issue the store holds when the run ends.
func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
if !dropClosed || p.State != "closed" {
return true
}
id, err := namer.idFor(p)
if err != nil {
// An id that cannot be allocated is the walk's failure to report, not a
// reason to spend the page budget differently.
return true
}
return pullStored(namer.root, id)
}
// pullBlockers is the numbers of the issues that block this one, in this
// repository.
//
// A blocker in ANOTHER repository is dropped here, and deliberately: everything
// downstream — `depends:`, the number -> slug ledger, the walk's own GETs — reads
// a bare number against the repository being pulled, so a foreign number would
// either resolve to the wrong issue or invent an edge. The body still names it,
// so nothing is lost.
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
deps, err := c.Dependencies(number)
if err != nil {
return nil, err
}
var out []int
for i := range deps {
if k := deps[i].KeyIn(repo); k.Repo == repo {
out = append(out, k.Number)
}
}
return out, nil
}
// pullSyncComments brings <id>.comments.md in line with the tracker and returns
// its path, or "" when the issue has no thread.
//
// count is the payload's own comment count, so an issue with none costs no
// request. A file from an earlier pull is removed when the thread is empty: the
// absence of the file is the answer, not a gap in what was asked for.
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
path := commentsSidecarPath(root, id)
var thread []wire.Comment
if count > 0 {
var err error
if thread, err = c.ListComments(number); err != nil {
return "", err
}
}
if len(thread) > 0 {
if err := os.WriteFile(path, []byte(mapping.RenderComments(thread)), 0o644); err != nil {
return "", err
}
return path, nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return "", err
}
return "", nil
}
// pullReceipt is the only thing that lands in a reader's context: one line per
// issue, the raw payload nowhere.
func pullReceipt(root string, written, skipped []string, indexPath string) error {
cached := map[string]bool{}
all := map[string]bool{}
for _, id := range written {
all[id] = true
}
for _, id := range skipped {
cached[id], all[id] = true, true
}
ids := make([]string, 0, len(all))
for id := range all {
ids = append(ids, id)
}
sort.Strings(ids)
graph := false
for _, id := range ids {
i, err := issue.Load(root, id)
if err != nil {
return err
}
graph = graph || len(i.Depends) > 0
note := ""
if cached[id] {
note = " (cached)"
}
if path := commentsSidecarPath(root, id); pullIsFile(path) {
n := i.Extra[mapping.CommentsKey]
if n == "" {
n = "?"
}
note += fmt.Sprintf(" +%s comments: %s", n, path)
}
labels := strings.Join(i.Labels, ", ")
if labels == "" {
labels = "no labels"
}
fmt.Printf("%s [%s] %s — %s %s%s\n",
id, labels, i.Title, i.State, issue.PathOf(root, id), note)
}
fmt.Printf("index: %s\n", indexPath)
// Worth printing when there is something to draw, not on every run that
// could have drawn something.
if graph {
fmt.Println("graph: run `kettle tree` (offline) to draw it")
}
return nil
}
// --------------------------------------------------------------------------
// naming, and the files the sync layer parks beside an issue
// --------------------------------------------------------------------------
// pullNamer answers "what is this remote issue called here", and remembers what
// it has already handed out so one run cannot name two issues the same thing.
type pullNamer struct {
root string
repo wire.Repo
ledger gitea.RemoteMap
// taken is every slug the store holds plus every one this run has claimed.
taken map[string]bool
}
// idFor is the slug this remote issue belongs under. Three sources, in order —
// see the command's own documentation for why that order and not another.
func (n *pullNamer) idFor(p *wire.Issue) (string, error) {
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: p.Number}); got != "" {
return got, nil
}
marked := mapping.IDInBody(p.Body)
// A marker is an identity only while the name is free. A file of that name
// already in the store, or a ledger entry holding it under another number,
// makes it a collision — and overwriting somebody else's issue is worse than
// allocating a suffix.
if marked != "" && !n.taken[marked] && !n.ledgerHolds(marked) {
return marked, nil
}
base := marked
if base == "" {
base = issue.Slugify(p.Title, 0)
}
taken := make([]string, 0, len(n.taken))
for id := range n.taken {
taken = append(taken, id)
}
return issue.UniqueID(n.root, base, taken)
}
func (n *pullNamer) ledgerHolds(slug string) bool {
for _, s := range n.ledger {
if s == slug {
return true
}
}
return false
}
// loadLedgerOrFold is the number -> slug ledger, with the `gitea:` fields still
// on disk folded in when there is no ledger to read.
//
// A MERGE and never a replacement, which is why the fold only happens when the
// file is missing or empty: push deletes the file it has just sent, so the store
// is a SUBSET of what the ledger knows and a rebuild from the files alone would
// throw away every entry it cannot see. What a fold cannot recover — a
// pushed-and-dropped issue whose entry was also lost — is not lost either: the
// next pull of that number reads the slug off the marker in the body and writes
// the entry back.
func loadLedgerOrFold(root string, issues map[string]*issue.Issue) gitea.RemoteMap {
m := gitea.LoadRemoteMap(root)
if len(m) > 0 {
return m
}
for id, i := range issues {
if k, ok := mapping.RemoteKeyOf(i); ok {
m.Set(k, id)
}
}
return m
}
// pullStored reports whether the store already holds this issue.
func pullStored(root, id string) bool { return pullIsFile(issue.PathOf(root, id)) }
func pullIsFile(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}