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>
This commit is contained in:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
package cmd
import (
"flag"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
var numberRe = regexp.MustCompile(`^\d+$`)
func init() {
register(&Command{
Name: "ac",
Group: GroupIssue,
Args: "<id>",
Short: "list and tick an issue's checkboxes",
Long: `A checkbox is the one part of a body that is *state* and not prose. Everything
else is written once; boxes get ticked as the work goes, and the only other ways
to tick one are a human with an editor or a model rewriting the whole body — the
second worse than the first, because the rewrite re-flows the text and the
issue's diff swells around a change of one character. This changes that one
character and nothing else.
Named after ` + "`## Acceptance criteria`" + `, where most boxes live, but every checkbox in
the body is listed and tickable: a type/feature keeps its children under
` + "`## Issues`" + `, and binding this to one heading would silently lose half of them.
A substring picks an item only when it picks exactly one. Two matches is an
error listing both — a coin flip would tick the wrong box and look like it
worked.
Delivering the changed body to a tracker is not part of this; that is
` + "`kettle push --update`" + `.`,
Examples: []Example{
{"kettle ac wire-sqlc-appclick", "numbered list with state"},
{"kettle ac wire-sqlc-appclick --check 3", "tick by number"},
{"kettle ac wire-sqlc-appclick --check регресс", "tick by substring"},
{"kettle ac wire-sqlc-appclick --uncheck 3", "untick it again"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
check := fs.String("check", "", "tick one item: number or substring")
uncheck := fs.String("uncheck", "", "untick one item: number or substring")
out := storeFlag(fs)
return func(args []string) error {
if len(args) != 1 {
return Fail("give exactly one issue id")
}
checking, unchecking := wasSet(fs, "check"), wasSet(fs, "uncheck")
if checking && unchecking {
return Fail("--check and --uncheck are mutually exclusive")
}
id := args[0]
root, err := storeRoot(*out)
if err != nil {
return err
}
path := issue.PathOf(root, id)
// Raw bytes in and raw bytes out: byte-for-byte means the line
// endings too. Reading a CRLF file with translation and writing
// it back would rewrite every line while claiming to have
// changed one character.
raw, err := os.ReadFile(path)
if err != nil {
return Fail("no issue %q in %s", id, root)
}
text := string(raw)
// The whole file, not just the body: line numbers then point at
// the file, and the metadata block is rewritten by nobody.
// Round-tripping through the parser would re-render metadata and
// re-strip the body, which is exactly the churn this avoids.
items := issue.Checkboxes(text)
needle, checked := *check, true
if unchecking {
needle, checked = *uncheck, false
}
selecting := checking || unchecking
if len(items) == 0 {
if selecting {
return Fail("%s has no checkboxes", id)
}
fmt.Printf("%s — no checkboxes\n", id)
return nil
}
if !selecting {
done, total := issue.CheckboxProgress(text)
fmt.Printf("%s — %d/%d %s\n", id, done, total, path)
fmt.Println(strings.Join(listing(items), "\n"))
return nil
}
item, err := selectItem(items, needle)
if err != nil {
return err
}
updated, err := issue.SetCheckbox(text, item.Line, checked)
if err != nil {
return err
}
if updated == text {
fmt.Printf("unchanged %2d %s %s\n", item.Index, box(item.Checked), item.Text)
return nil
}
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
return err
}
if _, _, err := issue.BuildIndex(root); err != nil {
return err
}
verb := "checked"
if !checked {
verb = "unchecked"
}
done, total := issue.CheckboxProgress(updated)
fmt.Printf("%s %2d %s %s\n", verb, item.Index, box(checked), item.Text)
fmt.Printf("%s — %d/%d %s:%d\n", id, done, total, path, item.Line)
return nil
}
},
})
}
func box(checked bool) string {
if checked {
return "[x]"
}
return "[ ]"
}
// listing is the numbered list, grouped by the heading each item sits under.
func listing(items []issue.Checkbox) []string {
var out []string
section := "\x00" // no heading can equal this, so the first item opens a group
for _, c := range items {
if c.Section != section {
section = c.Section
head := section
if head == "" {
head = "(above the first heading)"
}
out = append(out, "", head)
}
out = append(out, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
}
return out
}
// selectItem resolves a --check/--uncheck argument to exactly one item.
func selectItem(items []issue.Checkbox, needle string) (issue.Checkbox, error) {
needle = strings.TrimSpace(needle)
if needle == "" {
return issue.Checkbox{}, Fail("empty selector — give an item number or a substring")
}
if numberRe.MatchString(needle) {
n, _ := strconv.Atoi(needle)
if n < 1 || n > len(items) {
return issue.Checkbox{}, Fail("no item %d — the issue has %d", n, len(items))
}
return items[n-1], nil
}
var hits []issue.Checkbox
for _, c := range items {
if strings.Contains(strings.ToLower(c.Text), strings.ToLower(needle)) {
hits = append(hits, c)
}
}
switch len(hits) {
case 0:
return issue.Checkbox{}, Fail("nothing matches %q", needle)
case 1:
return hits[0], nil
}
lines := []string{fmt.Sprintf("%q matches %d items — narrow it down, or use a number:", needle, len(hits))}
for _, c := range hits {
lines = append(lines, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
}
return issue.Checkbox{}, Fail("%s", strings.Join(lines, "\n"))
}
+140
View File
@@ -0,0 +1,140 @@
package cmd
import (
"bufio"
"flag"
"fmt"
"io"
"os"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
func init() {
register(&Command{
Name: "auth",
Group: GroupProject,
Args: "list | add | remove <name>",
Short: "manage the tokens this machine holds",
Long: `Credentials live in one file per machine, outside every working tree, mode
0600. A project pins a login by NAME; the name is worth nothing on its own,
which is what makes it safe to keep in a file inside the repository.
The token is read from standard input unless --token is given, because an
argument is in the shell history the moment it is typed:
kettle auth add --name noodles --url https://git.example.com < token.txt
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
` + "`list`" + ` never prints a token. There is no flag to make it.`,
Examples: []Example{
{"kettle auth list", "what this machine holds"},
{"pass show gitea | kettle auth add --name noodles --url https://git.example.com", "add one, token on stdin"},
{"kettle auth remove noodles", "forget it"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
name := fs.String("name", "", "login name (add)")
url := fs.String("url", "", "instance URL, e.g. https://git.example.com (add)")
user := fs.String("user", "", "account this token belongs to; documentation only (add)")
token := fs.String("token", "", "token, if you would rather not use stdin (add)")
return func(args []string) error {
verb := "list"
if len(args) > 0 {
verb = args[0]
}
logins, err := config.LoadLogins()
if err != nil {
return err
}
switch verb {
case "list":
if len(logins.Logins) == 0 {
fmt.Printf("no logins in %s\n", config.LoginsPath())
return nil
}
fmt.Printf("%s\n\n", config.LoginsPath())
for _, l := range logins.Logins {
who := l.User
if who == "" {
who = "—"
}
fmt.Printf(" %-16s %-40s %s\n", l.Name, l.URL, who)
}
return nil
case "add":
if *name == "" || *url == "" {
return Fail("--name and --url are both required")
}
secret := *token
if secret == "" {
if secret, err = readToken(os.Stdin); err != nil {
return err
}
}
if secret == "" {
return Fail("no token — pipe one in, or pass --token")
}
entry := config.Login{
Name: *name,
URL: strings.TrimRight(*url, "/"),
User: *user,
Token: secret,
}
if existing := logins.Find(*name); existing != nil {
*existing = entry
} else {
logins.Logins = append(logins.Logins, entry)
}
if err := config.SaveLogins(logins); err != nil {
return err
}
fmt.Printf("%s -> %s %s\n", entry.Name, entry.URL, config.LoginsPath())
return nil
case "remove":
if len(args) != 2 {
return Fail("give exactly one login name to remove")
}
target := args[1]
kept := logins.Logins[:0]
found := false
for _, l := range logins.Logins {
if l.Name == target {
found = true
continue
}
kept = append(kept, l)
}
if !found {
return Fail("no login %q in %s", target, config.LoginsPath())
}
logins.Logins = kept
if err := config.SaveLogins(logins); err != nil {
return err
}
fmt.Printf("removed %s\n", target)
return nil
}
return Fail("unknown subcommand %q — list, add, or remove", verb)
}
},
})
}
// readToken takes the first non-empty line of r, trimmed.
//
// The first line, not the whole stream: a token piped from a password manager
// often arrives with a trailing newline and sometimes with notes underneath it.
func readToken(r io.Reader) (string, error) {
sc := bufio.NewScanner(r)
for sc.Scan() {
if line := strings.TrimSpace(sc.Text()); line != "" {
return line, nil
}
}
return "", sc.Err()
}
+110
View File
@@ -0,0 +1,110 @@
package cmd
import (
"flag"
"fmt"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "check",
Group: GroupIssue,
Args: "[<id>…]",
Short: "validate issues against the canonical format",
Long: `The same check the sync layer runs before it pushes anything, available on its
own so a local-only issue can be held to the format without a tracker being
involved.
Errors mean malformed; warnings mean it deviates from its type's template or its
graph looks suspect. An unticked checkbox is neither: work not done yet is the
normal state of a perfectly well-formed issue.
Exit status is 1 when anything has errors, which is what makes this usable in a
hook or a CI step.`,
Examples: []Example{
{"kettle check", "every issue in the store"},
{"kettle check wire-sqlc-appclick", "one issue"},
{"kettle check --quiet", "exit status only"},
{"kettle check --strict", "treat warnings as errors"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
quiet := fs.Bool("quiet", false, "exit status only, print nothing")
strict := fs.Bool("strict", false, "treat warnings as errors")
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
if err := issue.StoreError(root); err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
ids := args
if len(ids) == 0 {
for id := range issues {
ids = append(ids, id)
}
sort.Strings(ids)
}
known := map[string]bool{}
for id := range issues {
known[id] = true
}
for _, id := range ids {
if !known[id] {
return Fail("no issue %q in %s", id, root)
}
}
bad := 0
for _, id := range ids {
errs, warns := issue.Validate(issues[id], known)
if *strict {
errs, warns = append(errs, warns...), nil
}
if len(errs) > 0 {
bad++
}
if *quiet {
continue
}
if len(errs) == 0 && len(warns) == 0 {
fmt.Printf("ok %s\n", id)
continue
}
for _, e := range errs {
fmt.Printf("ERROR %s: %s\n", id, e)
}
for _, w := range warns {
fmt.Printf("warn %s: %s\n", id, w)
}
}
for _, c := range issue.FindCycles(issue.Graph(issues)) {
bad++
if !*quiet {
fmt.Printf("ERROR cycle: %s\n", strings.Join(c, " -> "))
}
}
if !*quiet {
fmt.Printf("%d issue(s) checked, %d with errors\n", len(ids), bad)
}
if bad > 0 {
return SilentError{Code: 1}
}
return nil
}
},
})
}
+522
View File
@@ -0,0 +1,522 @@
package cmd_test
// The CLI is tested the way the Python suite it replaces was: the binary is
// built once and run as a subprocess against a throwaway project somewhere
// else entirely.
//
// That separation IS the contract. A tool is installed in one place and used on
// projects in another, and the bug this discipline exists to catch — a store
// resolved from the executable's own directory rather than from the tree it was
// pointed at — is invisible to any test that runs the code in the directory it
// lives in.
//
// Every fixture also strips CLAUDE_PROJECT_DIR unless the test is about it: it
// is the first anchor of the walk, so the harness's own value would point every
// fixture at this repository.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var kettle string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "kettle-bin")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
kettle = filepath.Join(dir, "kettle")
build := exec.Command("go", "build", "-o", kettle, "../../cmd/kettle")
if out, err := build.CombinedOutput(); err != nil {
panic("building kettle: " + err.Error() + "\n" + string(out))
}
os.Exit(m.Run())
}
type result struct {
stdout, stderr string
code int
}
func (r result) out() string { return r.stdout + r.stderr }
// run invokes the binary in dir with a clean environment.
func run(t *testing.T, dir string, args ...string) result {
t.Helper()
return runWith(t, dir, nil, "", args...)
}
// runWith is run plus extra environment and standard input.
func runWith(t *testing.T, dir string, env []string, stdin string, args ...string) result {
t.Helper()
cmd := exec.Command(kettle, args...)
cmd.Dir = dir
cmd.Env = append(append(os.Environ(), "CLAUDE_PROJECT_DIR="), env...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var stdout, stderr strings.Builder
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
code := 0
var ee *exec.ExitError
if err != nil {
if !asExitError(err, &ee) {
t.Fatalf("running kettle %v: %v", args, err)
}
code = ee.ExitCode()
}
return result{stdout.String(), stderr.String(), code}
}
func mustRun(t *testing.T, dir string, args ...string) result {
t.Helper()
r := run(t, dir, args...)
if r.code != 0 {
t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out())
}
return r
}
// newProject makes an initialized project in a temp directory and returns it.
func newProject(t *testing.T) string {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
mustRun(t, dir, "init")
return dir
}
func TestInitIsIdempotentAndGitignoresTheStore(t *testing.T) {
dir := newProject(t)
for _, d := range []string{".kettle/issues", ".kettle/payload"} {
if fi, err := os.Stat(filepath.Join(dir, d)); err != nil || !fi.IsDir() {
t.Errorf("%s was not created", d)
}
}
// An `origin: local` issue is the only copy of that work, and what goes in
// a shared history is the operator's call, not this command's.
ignore, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
if err != nil || !strings.Contains(string(ignore), ".kettle/") {
t.Errorf(".kettle/ was not gitignored: %q", ignore)
}
again := mustRun(t, dir, "init")
if !strings.Contains(again.stdout, "already initialized") {
t.Errorf("a second init should be a no-op, got:\n%s", again.stdout)
}
}
func TestNoMarkerIsReportedNotGuessed(t *testing.T) {
dir := t.TempDir()
r := run(t, dir, "check")
if r.code == 0 {
t.Fatal("a directory that is not a project must not read as an empty store")
}
// The operator is owed the directories the search began from — that is how
// they see whether it began where they meant it to.
if !strings.Contains(r.stderr, "no .kettle/ found") || !strings.Contains(r.stderr, dir) {
t.Errorf("the failure must name what it searched:\n%s", r.stderr)
}
}
func TestTheGoldenPath(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "ok "+id) {
t.Errorf("a fresh issue from its own template must validate:\n%s", r.out())
}
// Progress is counted off the body every time, never stored.
mustRun(t, dir, "ac", id, "--check", "1")
index, err := os.ReadFile(filepath.Join(dir, ".kettle", "issues", "INDEX.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(index), "| 1/2 |") {
t.Errorf("the index did not pick up the ticked box:\n%s", index)
}
if r := mustRun(t, dir, "tree"); !strings.Contains(r.stdout, id) {
t.Errorf("tree did not draw the issue:\n%s", r.stdout)
}
}
func TestTickingABoxChangesOneByte(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Tick one box")
path := filepath.Join(dir, ".kettle", "issues", "tick-one-box.md")
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
mustRun(t, dir, "ac", "tick-one-box", "--check", "1")
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(before) != len(after) {
t.Fatalf("length changed: %d -> %d", len(before), len(after))
}
diff := 0
for i := range before {
if before[i] != after[i] {
diff++
}
}
if diff != 1 {
t.Errorf("%d bytes changed, want 1 — a tick must not re-render the file", diff)
}
// And back again, byte for byte: the metadata block is rewritten by nobody.
mustRun(t, dir, "ac", "tick-one-box", "--uncheck", "1")
back, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(back) != string(before) {
t.Error("unticking did not restore the file byte for byte")
}
}
func TestFlagsWorkAfterPositionalArguments(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Order of arguments")
// `kettle ac <id> --check 1` is how everybody types it. A flag silently
// read as a positional would tick nothing and report success.
r := mustRun(t, dir, "ac", "order-of-arguments", "--check", "1")
if !strings.Contains(r.stdout, "checked") {
t.Errorf("the flag after the id was ignored:\n%s", r.out())
}
}
func TestTheStoreResolvesFromAnywhereInsideTheProject(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Seen from below")
deep := filepath.Join(dir, "internal", "adapters")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
r := mustRun(t, deep, "check")
if !strings.Contains(r.stdout, "seen-from-below") {
t.Errorf("a subdirectory saw a different store:\n%s", r.out())
}
}
func TestADifferentProjectAnswersWithItsOwnStore(t *testing.T) {
a, b := newProject(t), newProject(t)
mustRun(t, a, "new", "--type", "task", "--title", "Belongs to A")
mustRun(t, b, "new", "--type", "task", "--title", "Belongs to B")
r := mustRun(t, b, "check")
if strings.Contains(r.stdout, "belongs-to-a") {
t.Errorf("project B saw project A's issues:\n%s", r.out())
}
}
func TestALocalIssueIsNeverEvictedEvenWhenNamed(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Only copy there is")
path := filepath.Join(dir, ".kettle", "issues", "only-copy-there-is.md")
closeIssue(t, path)
r := mustRun(t, dir, "evict", "only-copy-there-is")
if _, err := os.Stat(path); err != nil {
t.Fatal("a closed origin: local issue was deleted — that file IS the work")
}
if !strings.Contains(r.stdout, "kept") {
t.Errorf("keeping it must be said out loud:\n%s", r.out())
}
}
func TestAClosedTrackedIssueIsEvictedWithItsSidecars(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Done and elsewhere")
store := filepath.Join(dir, ".kettle", "issues")
path := filepath.Join(store, "done-and-elsewhere.md")
closeIssue(t, path)
setField(t, path, "origin", "gitea")
sidecar := filepath.Join(store, "done-and-elsewhere.comments.md")
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
t.Fatal(err)
}
// A dry run touches nothing, and says so.
dry := mustRun(t, dir, "evict", "--dry-run")
if !strings.Contains(dry.stdout, "would evict") {
t.Errorf("dry run said nothing:\n%s", dry.out())
}
if _, err := os.Stat(path); err != nil {
t.Fatal("a dry run deleted the issue")
}
mustRun(t, dir, "evict")
if _, err := os.Stat(path); err == nil {
t.Error("the issue survived eviction")
}
// The domain does not need to know what a comment thread is to know a file
// named after this issue goes when it goes.
if _, err := os.Stat(sidecar); err == nil {
t.Error("the sidecar was left behind")
}
}
func TestCheckExitsNonZeroOnAMalformedIssue(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Loses its type")
path := filepath.Join(dir, ".kettle", "issues", "loses-its-type.md")
setField(t, path, "labels", "[]")
r := run(t, dir, "check")
if r.code != 1 {
t.Errorf("exit = %d, want 1 — this is what makes check usable in a hook", r.code)
}
if !strings.Contains(r.stdout, "need exactly one type/* label") {
t.Errorf("the finding was not reported:\n%s", r.out())
}
}
func TestNewRefusesToOverwriteAnExistingIssue(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
// Without an explicit id the slug is allocated around the collision…
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "same-title-twice-2.md")); err != nil {
t.Error("the second issue did not get its own slug")
}
// …but an id typed by hand is taken literally, and taken means taken.
r := run(t, dir, "new", "--type", "task", "--title", "Third", "--id", "same-title-twice")
if r.code == 0 || !strings.Contains(r.stderr, "already exists") {
t.Errorf("an explicit id must not overwrite:\n%s", r.out())
}
}
// An older layout is migrated in, and it is a MOVE: a store left behind at the
// old path is a store somebody will edit by accident months later.
func TestInitMigratesAnOlderStore(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
old := filepath.Join(dir, ".tea", "issues")
if err := os.MkdirAll(old, 0o755); err != nil {
t.Fatal(err)
}
const body = "---\nid: from-the-old-store\nstate: open\nlabels: [type/task]\norigin: local\n---\n# From the old store\n\n## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n"
if err := os.WriteFile(filepath.Join(old, "from-the-old-store.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "init")
if !strings.Contains(r.stdout, "moved 1 file(s)") {
t.Errorf("the migration said nothing:\n%s", r.stdout)
}
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "from-the-old-store.md")); err != nil {
t.Fatal("the issue did not arrive in the new store")
}
if _, err := os.Stat(old); err == nil {
t.Error("the old store is still there — two stores is what the marker exists to prevent")
}
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "from-the-old-store") {
t.Errorf("the migrated issue is not readable:\n%s", r.out())
}
}
// A migration never picks a winner. Two files of the same name are two versions
// of one issue, and choosing quietly is how the wrong one survives.
func TestInitRefusesToResolveAMigrationClash(t *testing.T) {
dir := newProject(t)
old := filepath.Join(dir, ".tea", "issues")
if err := os.MkdirAll(old, 0o755); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "new", "--type", "task", "--title", "Both sides have this")
if err := os.WriteFile(filepath.Join(old, "both-sides-have-this.md"), []byte("older\n"), 0o644); err != nil {
t.Fatal(err)
}
r := run(t, dir, "init")
if r.code == 0 {
t.Fatal("a clash must stop the run")
}
if !strings.Contains(r.stderr, "both hold") || !strings.Contains(r.stderr, "nothing was changed") {
t.Errorf("the clash was not explained:\n%s", r.stderr)
}
if _, err := os.Stat(filepath.Join(old, "both-sides-have-this.md")); err != nil {
t.Error("the older file was moved anyway")
}
}
func TestInitWritesTheConfigAndKeepsWhatItWasNotGiven(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
mustRun(t, dir, "init", "--login", "noodles", "--repo", "claude-skills/marketplace")
cfg := filepath.Join(dir, ".kettle", "config.yaml")
raw, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "login: noodles") ||
!strings.Contains(string(raw), "repo: claude-skills/marketplace") {
t.Fatalf("config did not record what it was given:\n%s", raw)
}
// Re-running init to change one setting must not drop the other.
mustRun(t, dir, "init", "--repo", "claude-skills/other")
raw, err = os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "login: noodles") {
t.Errorf("the pinned login was dropped by an unrelated init:\n%s", raw)
}
if !strings.Contains(string(raw), "repo: claude-skills/other") {
t.Errorf("the repository was not updated:\n%s", raw)
}
}
func TestInitRefusesAMalformedRepo(t *testing.T) {
dir := t.TempDir()
r := run(t, dir, "init", "--repo", "marketplace")
if r.code == 0 || !strings.Contains(r.stderr, "owner/name") {
t.Errorf("a repo without an owner must be rejected before anything is written:\n%s", r.out())
}
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
t.Error("the marker was created despite the bad argument")
}
}
// The project pins a login by NAME. The credential lives in one file per
// machine, outside every working tree — a token in a repository ends up in a
// commit, and a secret that has been pushed has to be rotated.
func TestTokensNeverLandInTheProject(t *testing.T) {
dir := newProject(t)
home := t.TempDir()
env := []string{"KETTLE_CONFIG_HOME=" + home}
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add",
"--name", "noodles", "--url", "https://git.example.com/")
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
logins, err := os.ReadFile(filepath.Join(home, "logins.yaml"))
if err != nil {
t.Fatal("the token file was not written where it was told to go")
}
if !strings.Contains(string(logins), "s3cr3t-token") {
t.Errorf("the token was not stored:\n%s", logins)
}
if fi, err := os.Stat(filepath.Join(home, "logins.yaml")); err != nil || fi.Mode().Perm() != 0o600 {
t.Errorf("the token file must be 0600, got %v", fi.Mode().Perm())
}
cfg, err := os.ReadFile(filepath.Join(dir, ".kettle", "config.yaml"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(cfg), "s3cr3t-token") {
t.Fatal("the token was written into the project — that file ends up in a commit")
}
// And nothing prints it back, either.
shown := runWith(t, dir, env, "", "config")
if strings.Contains(shown.out(), "s3cr3t-token") {
t.Errorf("`kettle config` printed the token:\n%s", shown.out())
}
if !strings.Contains(shown.stdout, "https://git.example.com") {
t.Errorf("the resolved URL was not shown:\n%s", shown.out())
}
if !strings.Contains(shown.stdout, "token (set)") {
t.Errorf("whether a token was found must still be visible:\n%s", shown.stdout)
}
}
func TestAuthListNeverPrintsATokenAndRemoveForgetsIt(t *testing.T) {
dir := newProject(t)
home := t.TempDir()
env := []string{"KETTLE_CONFIG_HOME=" + home}
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles", "--url", "https://git.example.com")
listed := runWith(t, dir, env, "", "auth", "list")
if strings.Contains(listed.out(), "s3cr3t-token") {
t.Errorf("`auth list` printed a token:\n%s", listed.out())
}
if !strings.Contains(listed.stdout, "noodles") {
t.Errorf("`auth list` did not list the login:\n%s", listed.out())
}
runWith(t, dir, env, "", "auth", "remove", "noodles")
after := runWith(t, dir, env, "", "auth", "list")
if strings.Contains(after.stdout, "noodles") {
t.Errorf("the login survived removal:\n%s", after.stdout)
}
}
// A pinned login that is not on this machine is a fixable mistake, and the
// message has to say which file was read and what it holds.
func TestAMissingLoginIsExplained(t *testing.T) {
dir := newProject(t)
home := t.TempDir()
env := []string{"KETTLE_CONFIG_HOME=" + home}
mustRun(t, dir, "init", "--login", "absent", "--repo", "owner/name")
r := runWith(t, dir, env, "", "config")
if r.code == 0 {
t.Fatal("a login that does not exist must not resolve")
}
if !strings.Contains(r.stderr, `no login "absent"`) || !strings.Contains(r.stderr, "kettle auth add") {
t.Errorf("the failure must name the file and the fix:\n%s", r.stderr)
}
}
func closeIssue(t *testing.T, path string) {
t.Helper()
setField(t, path, "state", "closed")
}
func setField(t *testing.T, path, key, value string) {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(string(raw), "\n")
for i, line := range lines {
if strings.HasPrefix(line, key+": ") {
lines[i] = key + ": " + value
}
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatal(err)
}
}
func asExitError(err error, target **exec.ExitError) bool {
ee, ok := err.(*exec.ExitError)
if ok {
*target = ee
}
return ok
}
+327
View File
@@ -0,0 +1,327 @@
package cmd
import (
"flag"
"fmt"
"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: "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)
}
req := wire.IssueRequest{State: wire.Set(state)}
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 got.Number != t.key.Number || got.State != state {
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.Number, 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 *wire.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 got.UpdatedAt != "" {
i.Extra[mapping.RemoteUpdatedKey] = got.UpdatedAt
}
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
}
+269
View File
@@ -0,0 +1,269 @@
// Package cmd is the kettle command tree.
//
// Commands are values, not init() side effects on a framework: each one carries
// the metadata a human needs (what it does, what it takes, worked examples) in
// the same struct that carries the code. That is deliberate — the plugin's
// SKILL.md files are generated from this list, so a command whose flags changed
// cannot ship with documentation that says otherwise.
//
// The tree is flat. `kettle new`, not `kettle issue new`: an agent pays for
// every token of every invocation, and the grouping that matters for reading is
// carried in Group and only shows up in the docs.
package cmd
import (
"flag"
"fmt"
"os"
"sort"
"strings"
)
// Groups, in the order they are presented. They name the layer a command
// belongs to, which is the one thing a reader has to keep straight: the domain
// works offline and the tracker does not exist to it.
const (
GroupProject = "project"
GroupIssue = "issue"
GroupSync = "sync"
)
var groupOrder = []string{GroupProject, GroupIssue, GroupSync}
var groupBlurb = map[string]string{
GroupProject: "the project itself",
GroupIssue: "issues as units of work — offline, no tracker involved",
GroupSync: "moving issues between the store and the tracker",
}
// Example is one worked invocation. Both halves are shown in help and in the
// generated skill docs.
type Example struct {
Cmd string
What string
}
// Command is one verb.
type Command struct {
// Name is what the user types.
Name string
// Group is the layer it belongs to; documentation only.
Group string
// Args is the positional-argument spec, e.g. "<id> [<id>…]".
Args string
// Short is one line, shown in the command list.
Short string
// Long is the full explanation, shown by `kettle help <name>`.
Long string
// Examples are worked invocations.
Examples []Example
// Setup registers this command's flags on fs and returns the function that
// runs it, closing over them. Splitting it this way lets the doc generator
// walk the flags without running anything.
Setup func(fs *flag.FlagSet) func(args []string) error
}
var registry []*Command
func register(c *Command) { registry = append(registry, c) }
// Commands lists every command, sorted by group and then by name.
func Commands() []*Command {
out := append([]*Command{}, registry...)
sort.SliceStable(out, func(i, j int) bool {
gi, gj := groupIndex(out[i].Group), groupIndex(out[j].Group)
if gi != gj {
return gi < gj
}
return out[i].Name < out[j].Name
})
return out
}
// Lookup finds a command by name.
func Lookup(name string) *Command {
for _, c := range registry {
if c.Name == name {
return c
}
}
return nil
}
// Flags returns this command's flags without running it — what the doc
// generator walks.
func (c *Command) Flags() []*flag.Flag {
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
fs.SetOutput(discard{})
c.Setup(fs)
var out []*flag.Flag
fs.VisitAll(func(f *flag.Flag) { out = append(out, f) })
return out
}
// Usage is the one-line synopsis.
func (c *Command) Usage() string {
s := "kettle " + c.Name
if c.Args != "" {
s += " " + c.Args
}
return s
}
// SilentError carries an exit status for a command that has already said
// everything it has to say. `check` uses it: findings went to stdout and a
// second copy on stderr would be noise.
type SilentError struct{ Code int }
func (e SilentError) Error() string { return "" }
// Fail is the error every command returns for an ordinary failure. Main
// prefixes it with the command name.
func Fail(format string, a ...any) error { return fmt.Errorf(format, a...) }
// Main runs argv (without the program name) and returns the exit status.
func Main(argv []string) int {
if len(argv) == 0 {
printUsage(os.Stdout)
return 0
}
name := argv[0]
switch name {
case "help", "-h", "--help":
if len(argv) > 1 {
c := Lookup(argv[1])
if c == nil {
fmt.Fprintf(os.Stderr, "kettle: no command %q\n", argv[1])
return 2
}
printCommand(os.Stdout, c)
return 0
}
printUsage(os.Stdout)
return 0
}
c := Lookup(name)
if c == nil {
fmt.Fprintf(os.Stderr, "kettle: no command %q — try `kettle help`\n", name)
return 2
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.Usage = func() { printCommand(os.Stderr, c) }
run := c.Setup(fs)
if err := fs.Parse(permute(fs, argv[1:])); err != nil {
if err == flag.ErrHelp {
return 0
}
return 2
}
switch err := run(fs.Args()).(type) {
case nil:
return 0
case SilentError:
return err.Code
default:
fmt.Fprintf(os.Stderr, "kettle %s: %v\n", name, err)
return 1
}
}
func printUsage(w *os.File) {
fmt.Fprint(w, "kettle — issues as local markdown, and the tracker they sync with\n\n")
fmt.Fprint(w, "usage: kettle <command> [flags] [args]\n")
current := ""
for _, c := range Commands() {
if c.Group != current {
current = c.Group
fmt.Fprintf(w, "\n%s — %s\n", current, groupBlurb[current])
}
fmt.Fprintf(w, " %-11s %s\n", c.Name, c.Short)
}
fmt.Fprint(w, "\n`kettle help <command>` for one command in full.\n")
}
func printCommand(w *os.File, c *Command) {
fmt.Fprintf(w, "%s\n\n%s\n", c.Usage(), c.Short)
if c.Long != "" {
fmt.Fprintf(w, "\n%s\n", strings.TrimSpace(c.Long))
}
if flags := c.Flags(); len(flags) > 0 {
fmt.Fprint(w, "\nflags:\n")
for _, f := range flags {
name := "--" + f.Name
if f.DefValue != "" && f.DefValue != "false" {
name += "=" + f.DefValue
}
fmt.Fprintf(w, " %-22s %s\n", name, f.Usage)
}
}
if len(c.Examples) > 0 {
fmt.Fprint(w, "\nexamples:\n")
for _, e := range c.Examples {
fmt.Fprintf(w, " %s\n %s\n", e.Cmd, e.What)
}
}
}
// permute moves flags ahead of positional arguments.
//
// The standard flag package stops parsing at the first non-flag argument, so
// `kettle ac <id> --check 3` would hand --check to the command as a positional
// and tick nothing. Every other CLI an operator uses interleaves the two, and
// a tool that silently ignores a flag because of where it was typed is worse
// than one that rejects it.
//
// A flag that takes a value swallows the next argument, which is why this needs
// the FlagSet: only the set knows whether --check wants one. `--` ends the
// permutation, and everything after it is positional whatever it looks like.
func permute(fs *flag.FlagSet, args []string) []string {
var flags, positional []string
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
positional = append(positional, args[i+1:]...)
break
}
if len(a) < 2 || a[0] != '-' {
positional = append(positional, a)
continue
}
flags = append(flags, a)
if strings.Contains(a, "=") {
continue
}
f := fs.Lookup(strings.TrimLeft(a, "-"))
// An unknown flag consumes nothing; Parse will reject it by name in a
// moment, which is a better message than one about its value.
if f == nil || isBoolFlag(f.Value) {
continue
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
return append(flags, positional...)
}
func isBoolFlag(v flag.Value) bool {
b, ok := v.(interface{ IsBoolFlag() bool })
return ok && b.IsBoolFlag()
}
func groupIndex(g string) int {
for i, name := range groupOrder {
if name == g {
return i
}
}
return len(groupOrder)
}
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
+146
View File
@@ -0,0 +1,146 @@
package cmd
import (
"flag"
"fmt"
"os"
"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: "comment",
Group: GroupSync,
Args: "<id>",
Short: "post or edit a comment on a synced issue",
Long: `The target is a LOCAL ID, not a number. Which issue this is, is a fact about the
work; where it lives in the tracker is bookkeeping, and the ` + "`gitea:`" + ` handle on the
file is what turns one into the other. An ` + "`origin: local`" + ` issue cannot be
commented on at all — it is not in the tracker, so there is nothing there to
comment on; push it first.
The body comes from a file or from --body, and multi-line prose is what --file
is for. This is why comments go through the API rather than through a tracker
CLI: an entity command with an empty-looking positional opens $EDITOR, and on a
TTY that does not exist it hangs forever.
After the write the whole thread is refetched into ` + "`<id>.comments.md`" + `, so the
local copy is not stale by one comment — the one this run just made.
COMMENTS ARE PULL-ONLY IN THE STORE. Nothing round-trips them back: editing
` + "`<id>.comments.md`" + ` by hand changes nothing in the tracker. Use --edit with a
comment id for that.`,
Examples: []Example{
{"kettle comment wire-sqlc-appclick --file notes.md", "post the contents of a file"},
{`kettle comment wire-sqlc-appclick --body "готово, задеплоено"`, "post one line"},
{"kettle comment wire-sqlc-appclick --file fix.md --edit 1234", "rewrite comment 1234 instead"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
file := fs.String("file", "", "markdown file holding the comment body")
body := fs.String("body", "", "comment body inline (short, single-line)")
edit := fs.Int64("edit", 0, "comment id to rewrite, instead of posting a new one")
out := storeFlag(fs)
return func(args []string) error {
if len(args) != 1 {
return Fail("give exactly one issue id")
}
id := args[0]
withFile, withBody := wasSet(fs, "file"), wasSet(fs, "body")
switch {
case withFile && withBody:
return Fail("--file and --body are mutually exclusive")
case !withFile && !withBody:
return Fail("give the comment body: --file <path>, or --body \"…\"")
}
root, client, err := syncStartExisting(*out)
if err != nil {
return err
}
i, err := issue.Load(root, id)
if err != nil {
return Fail("no issue %q in %s", id, root)
}
key, ok := mapping.RemoteKeyOf(i)
if !ok {
return Fail("%s is local-only (origin: %s, no usable `%s:` handle) — "+
"there is nothing in the tracker to comment on; `kettle push %s` first",
id, i.Origin, mapping.GiteaKey, id)
}
text, err := commentBodyFrom(*file, *body)
if err != nil {
return err
}
// The handle names the repository, so a comment lands where the
// issue actually is — even when the store has ever pointed at two.
client = client.For(key.Repo)
var got *wire.Comment
verb := "posted"
if *edit != 0 {
verb = "edited"
got, err = client.EditComment(*edit, text, fmt.Sprintf("comment-%d", *edit))
} else {
got, err = client.CreateComment(key.Number, text, "comment-"+id)
}
if err != nil {
return err
}
// A 2xx that carries no id is not a comment. Nothing local has been
// written yet, and nothing will be if the answer is that shape.
if got.ID == 0 {
return Fail("the %s answer carries no comment id — nothing local was changed", verb)
}
fmt.Printf("%s comment %d on %s (%s) %s\n", verb, got.ID, id, key, got.HTMLURL)
comments, err := client.ListComments(key.Number)
if err != nil {
return Fail("the comment went up, but refetching the thread failed: %v — "+
"`kettle pull %d` to refresh the local copy", err, key.Number)
}
path := commentsSidecarPath(root, id)
if len(comments) == 0 {
// Only reachable when the thread was emptied elsewhere between
// the write and the read. A stale sidecar for a thread that no
// longer exists is worse than no sidecar.
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
fmt.Printf("thread: none — %s removed\n", path)
return nil
}
if err := os.WriteFile(path, []byte(mapping.RenderComments(comments)), 0o644); err != nil {
return err
}
fmt.Printf("thread: %s (%d comment(s))\n", path, len(comments))
return nil
}
},
})
}
// commentBodyFrom reads the comment body from a file or takes it as given.
//
// Trimmed and then required to be non-empty: a file of whitespace is somebody
// pointing at the wrong path, and posting it would leave an empty comment in a
// thread that nobody can delete from here.
func commentBodyFrom(file, inline string) (string, error) {
if file != "" {
raw, err := os.ReadFile(file)
if err != nil {
return "", Fail("cannot read the comment body: %v", err)
}
inline = string(raw)
}
text := strings.TrimSpace(inline)
if text == "" {
return "", Fail("the comment body is empty — nothing was posted")
}
return text, nil
}
+59
View File
@@ -0,0 +1,59 @@
package cmd
import (
"flag"
"fmt"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
func init() {
register(&Command{
Name: "config",
Group: GroupProject,
Short: "show what this project resolved to",
Long: `Every path and every setting, with the overrides already applied, so a run that
went somewhere unexpected can be explained without guessing.
The token is never printed — only whether one was found.
This is the command to reach for when the store looks empty, when a push says
401, or when two directories disagree about which project they are in.`,
Examples: []Example{
{"kettle config", "resolved paths and settings"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
return func(args []string) error {
root := project.Root("")
if root == "" {
return project.NotFoundError("")
}
fmt.Printf("project %s\n", root)
fmt.Printf("store %s\n", issue.Root(""))
fmt.Printf("payload %s\n", project.PayloadRoot(""))
fmt.Printf("config %s\n", config.ProjectPath(""))
fmt.Printf("logins %s\n", config.LoginsPath())
r, err := config.Resolve("")
if err != nil {
fmt.Println()
return err
}
red := r.Redacted()
fmt.Println()
fmt.Printf("login %s\n", orNone(red.Login))
fmt.Printf("url %s\n", orNone(red.URL))
fmt.Printf("token %s\n", orNone(red.Token))
if r.Owner != "" {
fmt.Printf("repo %s\n", r.Slug())
} else {
fmt.Printf("repo none\n")
}
return nil
}
},
})
}
+115
View File
@@ -0,0 +1,115 @@
package cmd
import (
"flag"
"fmt"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "evict",
Group: GroupIssue,
Args: "[<id>…]",
Short: "remove closed issues from the local store",
Long: `The store is a working set, not an archive. What is evicted is two conditions,
both read off the file:
state: closed the work is done
origin: <tracker> the work is somewhere else too
THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. ` + "`origin: local`" + ` means this
file IS the issue — there is no other copy and deleting it deletes the work. It
is never evicted, in any state, not even when named explicitly on the command
line: a closed local issue is reported and kept.
Eviction asks the file rather than the tracker, because state and origin are
domain fields and the answer is already in the store — which is why this needs
no network and no login. ` + "`kettle sync-evict`" + ` is the variant that refreshes state
from the tracker first and then makes the same decision.
Not a one-off migration: a pull by number fetches an issue in any state, so a
closed issue pulled after an eviction lands on disk again. Evict it again when
you are done with it.
INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug
ledger is deliberately not pruned: its entries outlive the files they name, and
that is what makes a pull land on the same slug afterwards.`,
Examples: []Example{
{"kettle evict", "every closed issue that is not origin: local"},
{"kettle evict old-thing another-thing", "only these"},
{"kettle evict --dry-run", "print what would go; touch nothing"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
dryRun := fs.Bool("dry-run", false, "print what would be removed; touch nothing")
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
if !issue.StoreExists(root) {
return Fail("store %s does not exist — nothing to evict", root)
}
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, ", "))
}
rep, err := issue.Evict(root, issues, args, *dryRun)
if err != nil {
return err
}
printEviction(rep, len(args) > 0)
return nil
}
},
})
}
func printEviction(rep *issue.EvictReport, named bool) {
verb := "evicted"
if rep.DryRun {
verb = "would evict"
}
for _, e := range rep.Evicted {
fmt.Printf("%-11s %s\n", verb, e.ID)
for _, p := range e.Paths {
fmt.Printf(" %s\n", p)
}
}
for _, k := range rep.Kept {
// An open issue is the normal case and says nothing worth a line —
// unless the operator named it, in which case they are owed the reason.
if k.Open && !named {
continue
}
if k.Open {
fmt.Printf("%-11s %s %s\n", "kept", k.ID, k.Why)
} else {
fmt.Printf("%-11s %s closed, %s\n", "kept", k.ID, k.Why)
}
}
if rep.DryRun {
fmt.Printf("%d issue(s) would be evicted, %d kept — nothing was touched\n",
len(rep.Evicted), len(rep.Kept))
return
}
fmt.Printf("%d issue(s) evicted, %d kept\n", len(rep.Evicted), len(rep.Kept))
if rep.IndexPath != "" {
fmt.Printf("index: %s — %d issue(s)\n", rep.IndexPath, rep.IndexCount)
}
}
+229
View File
@@ -0,0 +1,229 @@
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: "[<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.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
}
+54
View File
@@ -0,0 +1,54 @@
package cmd
import (
"flag"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// storeFlag registers the one flag almost every command has.
//
// An explicit --out overrides the resolved store and is used exactly as typed:
// a relative --out stays relative to the working directory, because that is
// what the operator asked for.
func storeFlag(fs *flag.FlagSet) *string {
return fs.String("out", "", "store root (default: <project>/.kettle/issues)")
}
// storeRoot resolves the store, or explains which directories were searched.
//
// No marker anywhere is an answer, not a fallback: a store placed in a
// plausible-looking directory is the failure the marker exists to replace.
func storeRoot(out string) (string, error) {
if root := issue.Root(out); root != "" {
return root, nil
}
return "", project.NotFoundError("")
}
// wasSet reports whether the operator actually typed this flag.
//
// Needed wherever the empty string is a legitimate value to reject rather than
// a synonym for "not given": `--check ""` is an empty selector and an error,
// while no --check at all means "just list the boxes".
func wasSet(fs *flag.FlagSet, name string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
// stringList is a repeatable flag: --label tech/sql --label comp/appclick.
type stringList []string
func (l *stringList) String() string { return strings.Join(*l, ", ") }
func (l *stringList) Set(v string) error {
*l = append(*l, v)
return nil
}
+322
View File
@@ -0,0 +1,322 @@
package cmd
import (
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
// The region markers. What sits between them belongs to the generator; the
// rest of the file belongs to whoever wrote it.
const (
genOpen = "<!-- kettle:gen -->"
genClose = "<!-- /kettle:gen -->"
)
// genBanner opens every generated region. The first thing anybody who finds
// the block wants to do is edit it in place, so the block says who wrote it and
// which command writes it again.
const genBanner = "**Generated from the kettle command registry by `kettle gen skills`.** " +
"Everything between the two markers is replaced on the next run — " +
"hand-written prose belongs outside them."
// exampleAlign is the widest example command that still gets its `# what`
// padded into a column. One long pipeline would otherwise push every other
// comment off the right edge of the page.
const exampleAlign = 56
func init() {
register(&Command{
Name: "gen",
Group: GroupProject,
Args: "skills",
Short: "write the plugin's SKILL.md files from the command registry",
Long: `A SKILL.md tells an agent how to invoke this binary. Hand-written, it drifts: a
flag is renamed here and the documentation goes on recommending the old one,
and the agent that reads it fails in a way nobody traces back to a stale
sentence. Everything those files say about a command — its usage line, its
flags with their defaults, its worked examples — is already in the registry
this binary is built from, so it is written from there and cannot disagree.
THE GENERATOR OWNS A REGION, NOT A FILE. Each SKILL.md carries a pair of HTML
comment markers — ` + "`kettle:gen`" + ` to open and ` + "`/kettle:gen`" + ` to close, both written in
the ` + "`<!-- … -->`" + ` form and visible at the top and bottom of the block below.
Everything between them is replaced on every run; every byte outside them comes
back exactly as it was, which matters most for ` + "`description:`" + `, the prose that
decides whether an agent loads the skill at all, and the one thing here that no
generator can write.
A file with no markers is REPORTED AND LEFT ALONE, never overwritten: clobbering
somebody's prose because they forgot a marker is the failure this design exists
to prevent. A file that does not exist yet is created with a frontmatter stub
around a generated block, for a human to fill in.
The output is deterministic to the byte — no timestamps, no map iteration — so
regenerating something that has not changed produces no diff. --check is that
property made useful: it writes nothing and exits 1 when any file on disk
differs from what would be generated, which is what a pre-commit hook or a CI
step calls. It wins over --dry-run when both are given.`,
Examples: []Example{
{"kettle gen skills --out ../plugins/tea/skills", "write the region in every group's SKILL.md"},
{"kettle gen skills --out ../plugins/tea/skills --dry-run", "print what would change; write nothing"},
{"kettle gen skills --out ../plugins/tea/skills --check", "exit 1 if the docs are out of date"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
dryRun := fs.Bool("dry-run", false, "print what would change; write nothing")
check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date")
return func(args []string) error {
target := "skills"
if len(args) > 0 {
target = args[0]
}
if len(args) > 1 || target != "skills" {
return Fail("the only target is `skills` — try `kettle gen skills --out <dir>`")
}
if *out == "" {
return Fail("--out is required — the directory the SKILL.md files live under")
}
return genSkills(*out, *dryRun, *check)
}
},
})
}
// errNoRegion is what a file that the generator may not touch reports.
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
func genSkills(dir string, dryRun, check bool) error {
// --check is a read-only question about the working tree, so it overrules
// --dry-run rather than combining with it.
if check {
dryRun = true
}
groups := docGroups()
var written, unchanged, outdated, kept int
for _, group := range groups {
path := filepath.Join(dir, group, "SKILL.md")
block, err := renderGroup(commandsIn(group))
if err != nil {
return err
}
existing, err := os.ReadFile(path)
switch {
case errors.Is(err, fs.ErrNotExist):
outdated++
if check {
fmt.Printf("%-13s %s\n", "missing", path)
continue
}
if dryRun {
fmt.Printf("%-13s %s\n", "would create", path)
continue
}
if err := writeFile(path, stubFile(group, block)); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "created", path)
case err != nil:
return err
default:
want, err := spliceRegion(string(existing), block)
if err != nil {
// Reported, never repaired: a missing marker is somebody's
// prose sitting where the block used to be.
kept++
fmt.Fprintf(os.Stderr, "kettle gen: %s left alone — %v\n", path, err)
continue
}
if want == string(existing) {
unchanged++
fmt.Printf("%-13s %s\n", "unchanged", path)
continue
}
outdated++
if check {
fmt.Printf("%-13s %s\n", "stale", path)
continue
}
if dryRun {
fmt.Printf("%-13s %s\n", "would update", path)
continue
}
if err := writeFile(path, want); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "updated", path)
}
}
switch {
case check:
fmt.Printf("%d file(s) checked, %d out of date, %d without a region\n",
len(groups), outdated, kept)
if outdated > 0 {
fmt.Printf("run `kettle gen skills --out %s`\n", dir)
return SilentError{Code: 1}
}
case dryRun:
fmt.Printf("%d file(s) would change, %d unchanged, %d without a region — nothing was written\n",
outdated, unchanged, kept)
default:
fmt.Printf("%d file(s) written, %d unchanged, %d without a region\n", written, unchanged, kept)
}
return nil
}
// docGroups lists the groups that have commands, in the order Commands()
// returns them — the same order twice, so two runs cannot differ.
func docGroups() []string {
var out []string
seen := map[string]bool{}
for _, c := range Commands() {
if c.Group == "" {
fmt.Fprintf(os.Stderr, "kettle gen: command %q has no group and is in no skill\n", c.Name)
continue
}
if !seen[c.Group] {
seen[c.Group] = true
out = append(out, c.Group)
}
}
return out
}
func commandsIn(group string) []*Command {
var out []*Command
for _, c := range Commands() {
if c.Group == group {
out = append(out, c)
}
}
return out
}
// renderGroup is the generated block for one group, without the markers and
// without a trailing newline.
func renderGroup(cmds []*Command) (string, error) {
var b strings.Builder
b.WriteString(genBanner)
b.WriteString("\n")
for _, c := range cmds {
text := renderCommand(c)
// A block holding either marker would cut itself in half on the next
// run — the splice would end the region in the middle of the prose that
// mentions it. Loud here rather than quietly truncated on disk.
if strings.Contains(text, genOpen) || strings.Contains(text, genClose) {
return "", Fail("command %q spells a region marker out in full; the generated block would then end inside itself — write it another way", c.Name)
}
b.WriteString(text)
}
return strings.TrimRight(b.String(), "\n"), nil
}
func renderCommand(c *Command) string {
var b strings.Builder
fmt.Fprintf(&b, "\n## `%s`\n\n%s\n", c.Usage(), c.Short)
if long := strings.TrimSpace(c.Long); long != "" {
b.WriteString("\n" + long + "\n")
}
if flags := c.Flags(); len(flags) > 0 {
b.WriteString("\n| flag | default | what it does |\n| --- | --- | --- |\n")
for _, f := range flags {
fmt.Fprintf(&b, "| `--%s` | %s | %s |\n", f.Name, defaultCell(f.DefValue), cell(f.Usage))
}
}
if len(c.Examples) > 0 {
w := exampleWidth(c.Examples)
b.WriteString("\n```bash\n")
for _, e := range c.Examples {
pad := w - utf8.RuneCountInString(e.Cmd)
if pad < 0 {
pad = 0
}
fmt.Fprintf(&b, "%s%s # %s\n", e.Cmd, strings.Repeat(" ", pad), e.What)
}
b.WriteString("```\n")
}
return b.String()
}
// exampleWidth is the column the `# what` comments line up at. Runes, not
// bytes: an example with Cyrillic in it would otherwise pull the column left by
// however many multi-byte characters it holds.
func exampleWidth(examples []Example) int {
w := 0
for _, e := range examples {
if n := utf8.RuneCountInString(e.Cmd); n > w && n <= exampleAlign {
w = n
}
}
return w
}
func defaultCell(v string) string {
if v == "" {
return "—"
}
return "`" + cell(v) + "`"
}
// cell keeps a value from breaking out of its table row.
func cell(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
return strings.ReplaceAll(s, "|", `\|`)
}
func region(block string) string {
return genOpen + "\n" + block + "\n" + genClose
}
// spliceRegion swaps the block into existing, leaving every other byte alone.
func spliceRegion(existing, block string) (string, error) {
start := strings.Index(existing, genOpen)
if start < 0 {
return "", errNoRegion
}
rest := start + len(genOpen)
end := strings.Index(existing[rest:], genClose)
if end < 0 {
return "", fmt.Errorf("%s is missing its %s", genOpen, genClose)
}
return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil
}
// stubFile is a new SKILL.md: the least frontmatter that is still a skill,
// and the region.
//
// The description is left as a TODO on purpose. It is the sentence that decides
// whether an agent loads this skill at all — prose a human tunes against real
// failures to trigger, and the one thing here a generator has no way to write.
func stubFile(group, block string) string {
title := "# kettle " + group + "\n"
if blurb := groupBlurb[group]; blurb != "" {
title += "\n" + blurb + "\n"
}
return "---\n" +
"name: " + group + "\n" +
"description: TODO — write this by hand. It is the only thing that decides whether an agent loads this skill at all, so it is prose a human tunes; kettle gen never reads or writes it.\n" +
"---\n\n" +
title + "\n" +
region(block) + "\n"
}
func writeFile(path, content string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(content), 0o644)
}
+281
View File
@@ -0,0 +1,281 @@
package cmd_test
// `kettle gen` writes documentation an agent reads to invoke this binary, into
// files a human also writes prose in. Both halves of that are tested here: what
// it produces has to be the same twice over, and what it does NOT own has to
// come back byte for byte.
import (
"os"
"path/filepath"
"strings"
"testing"
)
const (
genOpen = "<!-- kettle:gen -->"
genClose = "<!-- /kettle:gen -->"
)
func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
first := mustRun(t, dir, "gen", "skills", "--out", out)
for _, group := range []string{"project", "issue", "sync"} {
path := filepath.Join(out, group, "SKILL.md")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s was not created: %v\n%s", path, err, first.out())
}
body := string(raw)
// The frontmatter is what makes it a skill at all, and the description
// is prose a human tunes — the stub says so and generates nothing.
if !strings.HasPrefix(body, "---\nname: "+group+"\n") {
t.Errorf("%s has no frontmatter naming the group:\n%s", path, firstLines(body, 5))
}
if !strings.Contains(body, genOpen) || !strings.Contains(body, genClose) {
t.Errorf("%s was created without the region markers:\n%s", path, body)
}
// The block has to say what wrote it: the first thing anybody who finds
// it will want to do is edit it in place.
if !strings.Contains(body, "kettle gen skills") {
t.Errorf("%s does not name the command that regenerates it:\n%s", path, body)
}
}
// One command's documentation, end to end: usage line, short, a flag out of
// the flag set, and a worked example with its explanation beside it.
issues, err := os.ReadFile(filepath.Join(out, "issue", "SKILL.md"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"## `kettle evict [<id>…]`",
"remove closed issues from the local store",
"| `--dry-run` | `false` | print what would be removed; touch nothing |",
"kettle evict --dry-run",
"# print what would go; touch nothing",
} {
if !strings.Contains(string(issues), want) {
t.Errorf("the issue group is missing %q:\n%s", want, issues)
}
}
// Deterministic to the byte: a regeneration of something that has not
// changed must produce no diff at all, or every run of a CI step is a
// spurious one.
before := readAll(t, out)
second := mustRun(t, dir, "gen", "skills", "--out", out)
if strings.Contains(second.stdout, "updated") {
t.Errorf("a second run rewrote a file:\n%s", second.out())
}
for path, content := range before {
if now := readFile(t, path); now != content {
t.Errorf("%s changed on a second run with nothing else changed", path)
}
}
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 0 {
t.Errorf("--check exited %d on files that were just written:\n%s", r.code, r.out())
}
}
// The generator owns a region, not a file. Everything outside the markers is
// somebody's prose and comes back exactly as it was.
func TestGenLeavesHandWrittenProseAlone(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
mustRun(t, dir, "gen", "skills", "--out", out)
path := filepath.Join(out, "issue", "SKILL.md")
raw := readFile(t, path)
start := strings.Index(raw, genOpen)
end := strings.Index(raw, genClose) + len(genClose)
if start < 0 || end < len(genClose) {
t.Fatalf("no region in the generated file:\n%s", raw)
}
const above = "\n## Identity: the slug\n\nThe file name is the id, and it never changes.\n\n"
const below = "\n\n## Layering rule\n\nThis skill must keep working with the sync skill deleted.\n"
// A description a human tuned, in the frontmatter the generator must not
// touch: it is the only thing that decides whether the skill loads at all.
edited := strings.Replace(raw[:start], "description: TODO", "description: Work with this project's issues as units of work", 1)
edited += above + raw[start:end] + below
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "gen", "skills", "--out", out)
after := readFile(t, path)
if after != edited {
t.Errorf("a no-op regeneration did not return the file byte for byte:\n--- want ---\n%s\n--- got ---\n%s", edited, after)
}
// And the prose survives a regeneration that actually rewrites the block.
shortened := strings.Replace(after, genClose, "the block was gutted by hand\n"+genClose, 1)
if err := os.WriteFile(path, []byte(shortened), 0o644); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "gen", "skills", "--out", out)
restored := readFile(t, path)
if restored != edited {
t.Error("regenerating the block did not restore it, or did not preserve the prose around it")
}
if !strings.Contains(restored, "description: Work with this project's issues") {
t.Errorf("the hand-tuned description was overwritten:\n%s", firstLines(restored, 5))
}
if !strings.Contains(restored, above) || !strings.Contains(restored, below) {
t.Errorf("hand-written prose outside the markers was lost:\n%s", restored)
}
}
// Clobbering somebody's prose because they forgot a marker is the failure this
// whole design exists to prevent.
func TestGenNeverOverwritesAFileWithoutMarkers(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
path := filepath.Join(out, "issue", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
const prose = "---\nname: issue\ndescription: hand written, every word of it\n---\n\n# Everything here is somebody's work\n"
if err := os.WriteFile(path, []byte(prose), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "gen", "skills", "--out", out)
if got := readFile(t, path); got != prose {
t.Fatalf("a file with no markers was rewritten:\n%s", got)
}
// Left alone silently is how it drifts unnoticed, so it is reported — and
// on stderr, where a warning belongs.
if !strings.Contains(r.stderr, path) {
t.Errorf("the skipped file was not named on stderr:\n%s", r.out())
}
if !strings.Contains(r.stdout, "without a region") {
t.Errorf("the receipt did not account for it:\n%s", r.stdout)
}
// The other groups still got written — one unmanaged file stops nothing.
if _, err := os.Stat(filepath.Join(out, "sync", "SKILL.md")); err != nil {
t.Error("one file without markers stopped the whole run")
}
}
func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
mustRun(t, dir, "gen", "skills", "--out", out)
stale := filepath.Join(out, "sync", "SKILL.md")
raw := readFile(t, stale)
edited := strings.Replace(raw, genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
if err := os.WriteFile(stale, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
r := run(t, dir, "gen", "skills", "--out", out, "--check")
if r.code != 1 {
t.Fatalf("--check exited %d, want 1 — this is what a hook or a CI step calls:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, stale) {
t.Errorf("--check did not say which file is out of date:\n%s", r.out())
}
// A question about the tree, never an answer written into it.
if got := readFile(t, stale); got != edited {
t.Error("--check wrote to the file it was asked about")
}
// A file that is not there at all is out of date too, not a nothing.
if err := os.Remove(stale); err != nil {
t.Fatal(err)
}
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 1 {
t.Errorf("--check exited %d for a missing file, want 1:\n%s", r.code, r.out())
}
if _, err := os.Stat(stale); err == nil {
t.Error("--check created the file it was asked about")
}
}
func TestGenDryRunWritesNothingAtAll(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
fresh := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
if !strings.Contains(fresh.stdout, "would create") {
t.Errorf("a dry run said nothing about what it would do:\n%s", fresh.out())
}
if _, err := os.Stat(out); err == nil {
t.Fatal("a dry run created the output directory")
}
// And on an existing tree: the file is described, never touched.
mustRun(t, dir, "gen", "skills", "--out", out)
path := filepath.Join(out, "issue", "SKILL.md")
edited := strings.Replace(readFile(t, path), genClose, "gutted\n"+genClose, 1)
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
if !strings.Contains(r.stdout, "would update") || !strings.Contains(r.stdout, "nothing was written") {
t.Errorf("the dry run did not report the pending change:\n%s", r.out())
}
if got := readFile(t, path); got != edited {
t.Error("a dry run rewrote the file")
}
}
func TestGenRefusesAnUnknownTargetAndAMissingOut(t *testing.T) {
dir := t.TempDir()
if r := run(t, dir, "gen", "skills"); r.code == 0 || !strings.Contains(r.stderr, "--out") {
t.Errorf("gen without --out must stop and say so:\n%s", r.out())
}
if r := run(t, dir, "gen", "agents", "--out", filepath.Join(dir, "x")); r.code == 0 {
t.Errorf("an unknown target must be refused:\n%s", r.out())
}
if _, err := os.Stat(filepath.Join(dir, "x")); err == nil {
t.Error("the refused run created its output directory anyway")
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// readAll is every file under root, by path, for a byte-for-byte comparison
// after a second run.
func readAll(t *testing.T, root string) map[string]string {
t.Helper()
out := map[string]string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
out[path] = string(raw)
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
func firstLines(s string, n int) string {
lines := strings.SplitN(s, "\n", n+1)
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"flag"
"fmt"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "index",
Group: GroupIssue,
Short: "rebuild INDEX.md from what is on disk",
Long: `A map of the local store, nothing else. The ` + "`origin`" + ` column is the only place
the index acknowledges that a tracker exists: ` + "`local`" + ` means the issue has never
left this machine, anything else names the tracker it also lives in. Both are
ordinary issues here.
` + "`progress`" + ` counts the body's checkboxes, ticked over total, and is read off the
body at build time rather than stored — a second copy of that state in a
metadata field would be wrong by the next edit.
An existing store with nothing in it is a legitimate thing to index and gets an
"_empty_" table. A store that is not there is an error, not a directory to
create.`,
Examples: []Example{
{"kettle index", "rebuild the index for this project"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
path, n, err := issue.BuildIndex(root)
if err != nil {
return Fail("%v — nothing was created; create an issue with `kettle new`, or pass --out", err)
}
fmt.Printf("%s — %d issue(s)\n", path, n)
return nil
}
},
})
}
+157
View File
@@ -0,0 +1,157 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// writeConfig creates or updates .kettle/config.yaml, touching only the
// settings it was given.
//
// Init is idempotent, and that has to include the config: re-running it to add
// a repository must not silently drop the login somebody pinned last week.
func writeConfig(root, login, repo string, dryRun bool) (string, error) {
path := filepath.Join(root, project.Marker, "config.yaml")
rel := filepath.Join(project.Marker, "config.yaml")
cfg, existed, err := config.ReadProjectFile(path)
if err != nil {
return "", err
}
changed := !existed
if login != "" && cfg.Login != login {
cfg.Login, changed = login, true
}
if repo != "" && cfg.Repo != repo {
cfg.Repo, changed = repo, true
}
if !changed {
return "", nil
}
verb := "updated"
if !existed {
verb = "created"
}
detail := "no login or repository pinned yet — `kettle init --login … --repo …`"
if cfg.Login != "" || cfg.Repo != "" {
detail = fmt.Sprintf("login: %s, repo: %s", orNone(cfg.Login), orNone(cfg.Repo))
}
if dryRun {
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
}
if err := config.SaveProject(path, cfg); err != nil {
return "", err
}
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
func init() {
register(&Command{
Name: "init",
Group: GroupProject,
Short: "make this directory a project that tracks issues",
Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from,
and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues
belong to and which login to reach it under.
The marker is deliberately something an operator makes, not something inferred
from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from
one would write issues into whatever it happened to be installed in.
--login pins a name, never a credential. The tokens live in one file per
machine, outside every working tree, managed with ` + "`kettle auth`" + `.
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
an older store in if it finds one (tmp/ or .tea/), writes the config without
disturbing settings it was not given, and adds .kettle/ to .gitignore. Each
migration is a move, not a copy — two stores is the state the marker exists to
prevent — and it refuses to pick a winner when both sides hold a file of the
same name.
Do NOT run this inside a linked worktree. A worktree is the same project on
another branch and reaches the store by a hop out to the main checkout; a marker
here would give one project two stores, and the directory holding the second one
disappears with the branch.`,
Examples: []Example{
{"kettle init", "initialize the current directory"},
{"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"},
{"kettle init --at ~/code/x", "initialize somewhere else"},
{"kettle init --dry-run", "say what it would do, touch nothing"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
at := fs.String("at", "", "directory to initialize (default: the working directory)")
login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)")
repo := fs.String("repo", "", "tracker repository, as owner/name")
dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing")
return func(args []string) error {
root := *at
if root == "" {
wd, err := os.Getwd()
if err != nil {
return err
}
root = wd
}
root, err := filepath.Abs(root)
if err != nil {
return err
}
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
return Fail("%s is not a directory", root)
}
// A second marker inside an existing project gives it a second
// store, and the nearer one wins — which is a surprise worth
// naming before it happens, not after.
if existing := project.Root(root); existing != "" && existing != root {
fmt.Fprintf(os.Stderr,
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
root, existing)
}
if *repo != "" {
if owner, name, ok := strings.Cut(*repo, "/"); !ok || owner == "" || name == "" {
return Fail("--repo %q is not owner/name", *repo)
}
}
done, err := project.Init(root, *dryRun)
if err != nil {
return err
}
line, err := writeConfig(root, *login, *repo, *dryRun)
if err != nil {
return err
}
if line != "" {
done = append(done, line)
}
prefix := ""
if *dryRun {
prefix = "would: "
}
for _, l := range done {
fmt.Println(prefix + l)
}
return nil
}
},
})
}
+285
View File
@@ -0,0 +1,285 @@
package cmd
import (
"flag"
"fmt"
"os"
"regexp"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func init() {
register(&Command{
Name: "labels",
Group: GroupSync,
Short: "put the canonical type/* and severity/* labels into a repository",
Long: `Every ` + "`type/*`" + ` and every ` + "`severity/*`" + ` the domain taxonomy defines, created up
front instead of trickling in as a side effect of whichever push first happens
to use one. Until a name exists in the repository nobody can filter by it in the
web UI, so somebody makes their own — foreign colour, no ` + "`exclusive`" + ` — and the
set arrives in pieces over months.
NO LABEL NAME IS SPELLED OUT HERE. The names come from the domain taxonomy and
are painted by the mapping layer, because a hex code is how a tracker paints a
chip and not what an issue is. Add a type over in the domain and the next run
creates it.
THE REPOSITORY'S OWN LABELS ARE READ BEFORE ANYTHING IS WRITTEN, and read from
the repository, never from a cache — a cache answers "what did we create last
time" and the question here is "what does this repository have right now". A
name that matches exactly is left alone; a colour or ` + "`exclusive`" + ` that disagrees
with the spec is reported, and corrected only under --fix. A name that merely
RESEMBLES a canonical one (the same tail, up to case, separator and whatever
namespace is in front: ` + "`x`" + `, ` + "`X`" + `, ` + "`kind/x`" + `, ` + "`type: x`" + ` against ` + "`type/x`" + `) is
reported with its id and never touched — renaming somebody else's label is a
decision, not a step.
Out of scope by design: ` + "`tech/*`" + ` and ` + "`comp/*`" + `, which are open-ended and are
created by push as they come up, and deleting or renaming anything at all. Only
repository labels are read; an organization's own labels sit behind a different
endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally: a label belongs to
the repository and not to any issue, so this neither reads the store nor creates
it. Request bodies go to the transport's own scratchpad, which is a sibling of
the store and never a child.`,
Examples: []Example{
{"kettle labels --dry-run", "print the plan; not one writing request"},
{"kettle labels", "create whatever is missing"},
{"kettle labels --fix", "also patch colour / exclusive drift"},
{"kettle labels --repo owner/name", "bootstrap another repository"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
dryRun := fs.Bool("dry-run", false, "print the plan; not one writing request")
fix := fs.Bool("fix", false, "also patch colour/exclusive on labels that already exist")
repo := fs.String("repo", "", "repository to bootstrap, as owner/name (default: this project's)")
return func(args []string) error {
if len(args) > 0 {
return Fail("labels takes no arguments — the set comes from the taxonomy, not the command line")
}
// The store root is resolved and then deliberately dropped: this
// command must fail the same way as every other sync command when
// there is no project, and must touch no issue once there is one.
_, client, err := syncStart("")
if err != nil {
return err
}
if *repo != "" {
r, err := wire.ParseRepo(*repo)
if err != nil {
return Fail("--repo %v", err)
}
client = client.For(r)
}
specs := mapping.CanonicalLabelSpecs()
existing, err := client.ListLabels()
if err != nil {
return err
}
rows, similar := labelPlan(specs, existing)
created, fixed, drifted := 0, 0, 0
for _, row := range rows {
mark := ""
if row.spec.Exclusive {
mark = " exclusive"
}
if row.got == nil {
created++
if *dryRun {
fmt.Printf("create %-20s %s%s\n", row.spec.Name, row.spec.Color, mark)
continue
}
made, err := client.CreateLabel(row.spec)
if err != nil {
return err
}
fmt.Printf("created %-20s id %-5d %s%s\n", row.spec.Name, made.ID, row.spec.Color, mark)
continue
}
if len(row.drift) == 0 {
fmt.Printf("present %-20s id %d\n", row.spec.Name, row.got.ID)
continue
}
drifted++
shown := labelShowDrift(row.drift)
if !*fix {
fmt.Printf("present %-20s id %-5d drift: %s\n", row.spec.Name, row.got.ID, shown)
continue
}
if *dryRun {
fmt.Printf("fix %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
continue
}
// The unchanged name rides along because a server that reads an
// absent field as empty would blank it, and the description is
// the repository's own: a description somebody rewrote is
// theirs, and this run is about colour and exclusivity.
patch := row.spec
patch.Description = row.got.Description
if _, err := client.EditLabel(row.got.ID, patch); err != nil {
return err
}
fixed++
fmt.Printf("fixed %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
}
for _, s := range similar {
fmt.Fprintf(os.Stderr, "warning: %q (id %d) resembles %s — left alone; rename it by hand or ignore it\n",
s.name, s.id, strings.Join(s.hits, ", "))
}
verb := "created"
if *dryRun {
verb = "to create"
}
line := fmt.Sprintf("%d canonical label(s): %d %s, %d present",
len(rows), created, verb, len(rows)-created)
if drifted > 0 {
line += fmt.Sprintf(" (%d drifted, %d fixed)", drifted, fixed)
}
if len(similar) > 0 {
line += fmt.Sprintf(", %d similar", len(similar))
}
fmt.Println(line)
if drifted > 0 && !*fix {
fmt.Println("drift is shown, not applied — re-run with --fix to patch colour/exclusive")
}
if *dryRun {
fmt.Println("dry-run — nothing was written")
}
return nil
}
},
})
}
// labelRow is one canonical label, decided before anything is written: what the
// taxonomy says it should be, what the repository already has under that exact
// name (nil when it has nothing), and where the two disagree.
type labelRow struct {
spec wire.LabelRequest
got *wire.Label
drift []labelDiff
}
// labelDiff is one field that disagrees, with both readings, so a receipt can
// show the change without the caller re-deriving it.
type labelDiff struct{ field, is, want string }
// labelLookalike is a label of the repository's own that resembles a canonical
// name. Reported with its id and never touched.
type labelLookalike struct {
name string
id int64
hits []string
}
// labelPlan pairs the canonical set with what the repository holds.
//
// In taxonomy order, because a bootstrap prints its plan in that order and a map
// would shuffle it on every run — two identical runs would look like different
// ones.
func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []labelLookalike) {
byName := make(map[string]*wire.Label, len(existing))
for i := range existing {
byName[existing[i].Name] = &existing[i]
}
canonical := make(map[string]map[string]bool, len(specs))
rows := make([]labelRow, 0, len(specs))
for _, spec := range specs {
canonical[spec.Name] = labelAkin(spec.Name)
row := labelRow{spec: spec, got: byName[spec.Name]}
if row.got != nil {
row.drift = labelDrift(spec, row.got)
}
rows = append(rows, row)
}
var similar []labelLookalike
for i := range existing {
l := &existing[i]
if _, exact := canonical[l.Name]; exact {
continue
}
mine := labelAkin(l.Name)
var hits []string
for _, spec := range specs {
if labelIntersects(canonical[spec.Name], mine) {
hits = append(hits, spec.Name)
}
}
if len(hits) > 0 {
similar = append(similar, labelLookalike{name: l.Name, id: l.ID, hits: hits})
}
}
return rows, similar
}
// labelDrift is where an existing label disagrees with the spec.
//
// Colour and `exclusive` only. A description somebody rewrote is theirs, and the
// name matched exactly or this row would not exist.
func labelDrift(spec wire.LabelRequest, got *wire.Label) []labelDiff {
var out []labelDiff
if labelHex(got.Color) != labelHex(spec.Color) {
out = append(out, labelDiff{"color", labelHex(got.Color), labelHex(spec.Color)})
}
if got.Exclusive != spec.Exclusive {
out = append(out, labelDiff{"exclusive",
fmt.Sprintf("%t", got.Exclusive), fmt.Sprintf("%t", spec.Exclusive)})
}
return out
}
// labelHex normalizes a colour for comparison. Gitea reports them bare
// (`ee0701`) and the mapping layer writes them with a `#`; same colour, so a
// comparison has to strip before it compares.
func labelHex(v string) string { return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), "#")) }
var labelWords = regexp.MustCompile(`[^a-z0-9]+`)
// labelAkin is the comparison keys for a label name: its tail, and the whole
// name squashed.
//
// Case, separators and the namespace in front are noise — what a person meant is
// the tail. `x`, `X` and `kind/x` all reduce to the same tail as `type/x`, and
// `severity: x y` to the same squashed form as `severity/xy`. Two names resemble
// each other when these sets intersect.
func labelAkin(name string) map[string]bool {
var parts []string
for _, p := range labelWords.Split(strings.ToLower(name), -1) {
if p != "" {
parts = append(parts, p)
}
}
if len(parts) == 0 {
return nil
}
return map[string]bool{parts[len(parts)-1]: true, strings.Join(parts, ""): true}
}
func labelIntersects(a, b map[string]bool) bool {
for k := range a {
if b[k] {
return true
}
}
return false
}
func labelShowDrift(drift []labelDiff) string {
var out []string
for _, d := range drift {
out = append(out, fmt.Sprintf("%s %s -> %s", d.field, d.is, d.want))
}
return strings.Join(out, ", ")
}
+144
View File
@@ -0,0 +1,144 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "new",
Group: GroupIssue,
Short: "create a local issue from its type template",
Long: `The issue is real the moment this writes the file. Nothing is pending, nothing
is a draft awaiting a tracker: ` + "`origin: local`" + ` is a complete state and pushing it
later is optional.
While it says local, this file is the ONLY copy of the work — the store, not a
cache of anything. That is what a push changes: it hands the issue to the
tracker and removes the file.
Writes .tea/issues/<slug>.md prefilled with the type's template, prints the
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
` + "`kettle check <id>`" + `.
Body prose is Russian, section headers and the title are English.`,
Examples: []Example{
{`kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick`,
"a task with two free-form labels"},
{`kettle new --type bug --title "Fix tea-guard crash on empty settings" --depends wire-sqlc-appclick --milestone v0.2`,
"a bug that is blocked by another issue"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
typ := fs.String("type", "", "issue type, one of: "+strings.Join(issue.TypeNames(), ", ")+" (becomes the exclusive type/* label)")
title := fs.String("title", "", "English, imperative, no type prefix")
id := fs.String("id", "", "slug (default: derived from the title)")
severity := fs.String("severity", "", "severity/* label, one of: "+strings.Join(issue.Severities, ", "))
milestone := fs.String("milestone", "", "milestone title")
var labels, assignees, depends stringList
fs.Var(&labels, "label", "extra label, e.g. tech/sql; repeat")
fs.Var(&assignees, "assignee", "assignee login; repeat")
fs.Var(&depends, "depends", "id this issue depends on; repeat")
out := storeFlag(fs)
return func(args []string) error {
if *typ == "" || *title == "" {
return Fail("--type and --title are both required")
}
if !issue.KnownType(*typ) {
return Fail("unknown --type %q — known: %s", *typ, strings.Join(issue.TypeNames(), ", "))
}
if *severity != "" && !issue.KnownSeverity(*severity) {
return Fail("unknown --severity %q — known: %s", *severity, strings.Join(issue.Severities, ", "))
}
// Before anything reads the store path. There is no store to be
// second-guessed about when there is no project.
root, err := storeRoot(*out)
if err != nil {
return err
}
labelSet := []string{"type/" + *typ}
if *severity != "" {
labelSet = append(labelSet, "severity/"+*severity)
}
for _, l := range labels {
if !contains(labelSet, l) {
labelSet = append(labelSet, l)
}
}
slug := *id
if slug == "" {
if slug, err = issue.UniqueID(root, issue.Slugify(*title, 0), nil); err != nil {
return err
}
} else if !issue.IsSlug(slug) {
return Fail("--id %q is not a slug (lowercase, digits, single dashes)", slug)
}
if _, err := os.Stat(issue.PathOf(root, slug)); err == nil {
return Fail("%s already exists", issue.PathOf(root, slug))
}
known := map[string]bool{}
for _, k := range issue.AllIDs(root) {
known[k] = true
}
for _, d := range depends {
if !known[d] {
fmt.Fprintf(os.Stderr, "warning: depends on %q, which is not in the store yet\n", d)
}
}
i := &issue.Issue{
ID: slug,
Title: *title,
Body: issue.Template(*typ, depends),
State: "open",
Labels: labelSet,
Assignees: assignees,
Milestone: *milestone,
Depends: depends,
Origin: issue.Local,
}
// The first issue in a fresh checkout has to create the store,
// but it says so — and it says where, because the path is
// absolute.
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)
}
path, err := issue.Save(root, i)
if err != nil {
return err
}
if _, _, err := issue.BuildIndex(root); err != nil {
return err
}
fmt.Printf("%s [type/%s] %s\n", path, *typ, *title)
fmt.Printf("fill the sections, then: kettle check %s\n", slug)
return nil
}
},
})
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
+594
View File
@@ -0,0 +1,594 @@
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()
}
+652
View File
@@ -0,0 +1,652 @@
package cmd
import (
"flag"
"fmt"
"os"
"os/exec"
"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: "push",
Group: GroupSync,
Args: "[<id>…]",
Short: "send local issues to the tracker; the local copy goes with them",
Long: `A SUCCESSFUL PUSH DELETES THE LOCAL FILE — <id>.md and every sidecar under that
slug — and prints the number and the URL the issue now lives at. Once the tracker
has the issue, the tracker IS the issue: what is left in the store is what has
not left this machine. Get it back with ` + "`kettle pull <n>`" + `, which returns it under
the same slug, because the slug travelled up in the body as <!-- kettle:id … -->
and was recorded in the number -> slug ledger.
ONE RULE, NO EXCEPTION: --update deletes as well. A PATCH is a push, and an issue
that has just been sent is no more local than one that was just created. Two
rules would put back exactly the question this removes — "is my copy the fresh
one?".
THE DELETION IS THE LAST THING THAT HAPPENS TO AN ISSUE, and only after all
three of:
1. the call came back without an error and with a 2xx,
2. the answer carries a plausible number — on --update the very number that
was PATCHed, and
3. the ledger has been written with number -> slug.
Network down, non-2xx, an answer that does not confirm the write: the file stays
and the run stops. Nothing removes a file it has not just watched the tracker
accept, and nothing removes a file for an issue it did not send — ` + "`origin: local`" + `
work that has never been pushed is never touched by any of this. Get the ordering
wrong and a slug is lost at exactly the moment the local copy stops being the
record, which is why the ledger is written before anything is deleted and not
after.
Every issue is validated against the canonical format first, offline and before
a socket is opened. --force posts anyway; say why when you use it.
DEPENDENCIES GO FIRST, in topological order, so a blocker has its number before
the issue that names it. Every ` + "`depends:`" + ` entry that has a number becomes a NATIVE
tracker link — the same /dependencies a pull reads back, so the tracker shows the
blocking panel and refuses to close a blocked issue first. A link that is already
there is skipped, not re-POSTed, which is what makes a repeat push a no-op. A
dependency that is still local-only has no number and becomes no link: it is
reported, never silently dropped.
REMOVING a link is out of scope — push only ever adds. A dependency deleted from
` + "`depends:`" + ` leaves its tracker link standing; unlink it in the web UI.
The ` + "`## Depends on`" + ` prose is never touched: slugs stay slugs and are not rewritten
to #N, so a pull -> push round trip is byte for byte.
Labels the repository is missing are created with the canonical colour and, for
type/* and severity/*, exclusive: true. ` + "`branch:`" + ` carries the tracker's ` + "`ref`" + `: an
empty one is filled with the current git branch and an already-set one is sent as
written. Detached HEAD or no repository at all is not an error — no ref is sent
and a warning says so.`,
Examples: []Example{
{"kettle push", "every issue the tracker does not have yet, blockers first"},
{"kettle push wire-sqlc-appclick", "one issue"},
{"kettle push --update wire-sqlc-appclick", "PATCH one that is already there — the file still goes"},
{"kettle push --dry-run", "validate and print the plan; no network, nothing deleted"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
update := fs.Bool("update", false, "PATCH issues that already carry a gitea: field")
dryRun := fs.Bool("dry-run", false, "validate and print the plan; no network, nothing deleted")
force := fs.Bool("force", false, "push despite format violations")
out := storeFlag(fs)
return func(args []string) error {
// A dry run touches no network, so it must not need a credential
// to say what it would do. The real run goes through
// syncStartExisting below, which resolves the store before it
// builds a client — a missing store reported as a network
// problem sends the operator to the wrong place.
root, err := storeRoot(*out)
if err != nil {
return err
}
if err := issue.StoreError(root); err != nil {
return Fail("%s — create an issue with `kettle new` first", err)
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
chosen, err := pushSelect(issues, args, *update)
if err != nil {
return err
}
// The domain's own check, offline, before anything is sent.
known := map[string]bool{}
for id := range issues {
known[id] = true
}
blocked := false
for _, id := range chosen {
errs, warns := issue.Validate(issues[id], known)
for _, w := range warns {
fmt.Fprintf(os.Stderr, "warning: %s: %s\n", id, w)
}
for _, e := range errs {
fmt.Fprintf(os.Stderr, "%s: %s\n", id, e)
}
blocked = blocked || len(errs) > 0
}
if blocked && !*force {
return Fail("format violations (see above); --force overrides")
}
// Dependencies first, so a blocker has its number by the time the
// issue that names it is sent. A cycle is reported and ordered
// around rather than refused: it is a data problem, not a reason
// to send nothing.
edges := map[string][]string{}
for _, id := range chosen {
var deps []string
for _, d := range issues[id].Depends {
if _, ok := issues[d]; ok {
deps = append(deps, d)
}
}
edges[id] = deps
}
pushing := map[string]bool{}
for _, id := range chosen {
pushing[id] = true
}
var order []string
for _, id := range issue.TopoOrder(chosen, edges) {
if pushing[id] {
order = append(order, id)
}
}
for _, c := range issue.FindCycles(edges) {
fmt.Fprintf(os.Stderr, "warning: dependency cycle: %s\n", strings.Join(c, " -> "))
}
// Only an EMPTY branch: one written by hand is the author's
// decision and a push does not argue with it. The value is set on
// the in-memory issue only — the file it came from is about to be
// deleted, and the branch comes back with the next pull.
var blank []string
for _, id := range order {
if strings.TrimSpace(issues[id].Extra[mapping.BranchKey]) == "" {
blank = append(blank, id)
}
}
if len(blank) > 0 {
if branch := pushGitBranch(); branch != "" {
for _, id := range blank {
if issues[id].Extra == nil {
issues[id].Extra = map[string]string{}
}
issues[id].Extra[mapping.BranchKey] = branch
}
} else {
fmt.Fprintf(os.Stderr, "warning: no current git branch (detached HEAD, or "+
"outside a git repository) — no `ref` on: %s\n", strings.Join(blank, ", "))
}
}
if *dryRun {
// Not one request is made here: everything below is read off
// the store and the ledger, which costs nothing.
pushPlan(root, issues, order, pushing, *update)
return nil
}
_, client, err := syncStartExisting(*out)
if err != nil {
return err
}
repo := client.Repo()
var wanted []string
for _, id := range order {
for _, l := range issues[id].Labels {
if !contains(wanted, l) {
wanted = append(wanted, l)
}
}
}
sort.Strings(wanted)
labelIDs, err := pushLabelIDs(client, wanted)
if err != nil {
return err
}
milestones := map[string]*int64{}
ledger := loadLedgerOrFold(root, issues)
keyOf := pushLedgerKeys(ledger, repo)
for _, id := range order {
i := issues[id]
// Local-only means "this machine has never sent it": no
// `gitea:` on the file AND no entry in the ledger. A blocker
// whose file an earlier push already dropped is in the ledger
// and is not one of these.
var unsynced []string
for _, d := range i.Depends {
dep, onDisk := issues[d]
if !onDisk || pushing[d] {
continue
}
if _, synced := mapping.RemoteKeyOf(dep); synced {
continue
}
if _, inLedger := keyOf[d]; !inLedger {
unsynced = append(unsynced, d)
}
}
if len(unsynced) > 0 {
fmt.Fprintf(os.Stderr, "warning: %s: depends on local-only issue(s) %s"+
" — no cross-link in the tracker\n", id, strings.Join(unsynced, ", "))
}
msID, ok := milestones[i.Milestone]
if i.Milestone != "" && !ok {
m, err := client.FindMilestone(i.Milestone)
if err != nil {
return err
}
if m != nil {
msID = wire.Set(m.ID)
}
milestones[i.Milestone] = msID
}
if i.Milestone != "" && msID == nil {
fmt.Fprintf(os.Stderr, "warning: %s: milestone %q does not exist in %s"+
" — not set\n", id, i.Milestone, repo)
}
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
sent, synced := mapping.NumberOf(i)
var got *wire.Issue
verb := "created"
if synced {
// An edit says what state it means; a create takes the
// tracker's default.
opt.IncludeState = true
verb = "updated"
got, err = client.EditIssue(sent, *mapping.ToRequest(i, opt), "issue-"+id)
} else {
sent = 0
got, err = client.CreateIssue(*mapping.ToRequest(i, opt), "issue-"+id)
}
// THE GATE. Below this line a local file is going to be
// deleted, so anything short of a confirmed write stops the
// run right here.
if err != nil {
return Fail("%s: not %s: %v — %s is untouched", id, verb, err, issue.PathOf(root, id))
}
number, confirmed := pushConfirmedNumber(got, sent)
if !confirmed {
return Fail("%s: the tracker's answer does not confirm the write "+
"(it carries number %d) — %s is untouched, nothing was deleted",
id, got.Number, issue.PathOf(root, id))
}
// The number is confirmed, so the ledger learns it NOW —
// before the label fix-up and the links, both of which can
// still fail, and well before the file is removed. This entry
// is what a later `kettle pull <n>` lands on; an interrupted
// run must cost a re-pull, never a slug.
key := wire.Key{Repo: repo, Number: number}
ledger.Set(key, id)
keyOf[id] = key
if err := ledger.Save(root); err != nil {
return Fail("%s: the tracker has it as #%d but the ledger could not be "+
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
}
// Gitea occasionally drops labels handed to it on create, so
// the echo is checked and the set re-applied rather than
// trusted. A failure here is a warning and not an abort: the
// issue IS in the tracker, and a run that stopped now would
// leave a file whose `gitea:` field was never written — which
// the next push would file all over again as a new issue.
applied := map[string]bool{}
for _, l := range got.Labels {
applied[l.Name] = true
}
var ids []int64
var missing []string
for _, name := range i.Labels {
lid, in := labelIDs[name]
if !in {
continue
}
ids = append(ids, lid)
if !applied[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
if _, err := client.SetLabels(number, ids, "labels-"+id); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not re-apply labels (%s): %v\n",
id, strings.Join(missing, ", "), err)
} else {
fmt.Fprintf(os.Stderr, "warning: %s: labels re-applied via PUT (%s)\n",
id, strings.Join(missing, ", "))
}
}
// The in-memory issue is stamped even though its file is
// going: the rest of this loop reads `gitea:` off it to link
// dependencies, and a later issue in topological order asks
// the same of this one.
mapping.ApplyRemote(i, got, repo, time.Now().UTC().Format(time.RFC3339))
// The number and the URL lead, because in a moment the local
// path is gone and this is the only address the issue has.
fmt.Printf("%s %s #%d %s\n", verb, id, number, got.HTMLURL)
if err := pushLinks(client, id, number, i, issues, pushing, keyOf, repo); err != nil {
return err
}
// And now the local copy goes: the last thing that happens to
// this issue, after the write, the ledger and the links. A
// warning above lands here anyway — the issue is in the
// tracker, and keeping a stale file beside it would put back
// exactly the two-copies question this removes.
gone, err := issue.Remove(root, id)
for _, p := range gone {
fmt.Printf(" dropped %s\n", p)
}
if err != nil {
return Fail("%s: the tracker has it as #%d, but the local copy could not "+
"be removed: %v", id, number, err)
}
fmt.Printf(" kettle pull %d to work on it again\n", number)
}
if err := ledger.Save(root); err != nil {
return err
}
path, n, err := issue.BuildIndex(root)
if err != nil {
return err
}
fmt.Printf("index: %s — %d issue(s)\n", path, n)
return nil
}
},
})
}
// pushSelect is which issues to send, and the refusal of the ambiguous
// combinations.
//
// Named ids are taken as typed. With none, the default is everything this
// machine has never sent — pushing the whole store on a bare `kettle push` would
// re-PATCH every working copy in it.
func pushSelect(issues map[string]*issue.Issue, ids []string, update bool) ([]string, error) {
var chosen []string
if len(ids) > 0 {
var missing []string
for _, id := range ids {
if _, ok := issues[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return nil, Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
}
chosen = append(chosen, ids...)
} else {
for id, i := range issues {
if _, synced := mapping.RemoteKeyOf(i); update || !synced {
chosen = append(chosen, id)
}
}
sort.Strings(chosen)
if len(chosen) == 0 {
return nil, Fail("nothing to push: every issue in the store is already in the " +
"tracker — pass --update to PATCH them, or `kettle new` to make one")
}
}
if !update {
var already []string
for _, id := range chosen {
if _, synced := mapping.RemoteKeyOf(issues[id]); synced {
already = append(already, id)
}
}
if len(already) > 0 {
return nil, Fail("already in the tracker: %s — pass --update to PATCH them",
strings.Join(already, ", "))
}
}
return chosen, nil
}
// pushLabelIDs is name -> id for the labels these issues carry, creating what
// the repository is missing.
//
// Decided against the repository as it is right now, in one request, and never
// against a cache: a cache answers "what did we create last time", and the
// question here is "what does this repository have". A label the tracker does
// not have and this cannot create is the one failure worth stopping for — an
// issue filed without its `type/*` label is an issue nothing can find again.
func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
out := map[string]int64{}
if len(names) == 0 {
return out, nil
}
have, err := c.ListLabels()
if err != nil {
return nil, err
}
known := make(map[string]int64, len(have))
for _, l := range have {
known[l.Name] = l.ID
}
// The spec — colour, description, exclusivity — is the bridge's, read off the
// domain's taxonomy. This layer only decides which names are wanted.
for _, spec := range mapping.LabelSpecs(names) {
if id, ok := known[spec.Name]; ok {
out[spec.Name] = id
continue
}
created, err := c.CreateLabel(spec)
if err != nil {
return nil, err
}
out[spec.Name] = created.ID
note := ""
if spec.Exclusive {
note = " (exclusive)"
}
fmt.Fprintf(os.Stderr, "created label %s%s\n", spec.Name, note)
}
return out, nil
}
// pushDep is what one `depends:` entry is, as far as linking is concerned.
type pushDep struct {
// Slug is the dependency as `depends:` spells it.
Slug string
// Key is where it lives in the tracker; HasKey is false while it is
// local-only.
Key wire.Key
HasKey bool
// InRun says this push is about to give it a number.
InRun bool
}
// pushDepState is every dependency this run can say anything about.
//
// A dependency's key is read from its `gitea:` field while the file is still on
// disk, and from the ledger when it is not — which, since push deletes what it
// sends, is the normal state of an already-published blocker. Without that
// fallback the graph would quietly lose an edge every time a blocker was pushed
// before its dependent: the file is gone, the field goes with it, and the link is
// never made.
//
// A slug that is in neither the store nor the ledger names nothing this machine
// has ever seen, and is dropped — validation has already warned about it.
func pushDepState(i *issue.Issue, issues map[string]*issue.Issue, pushing map[string]bool,
keyOf map[string]wire.Key) []pushDep {
var out []pushDep
for _, d := range i.Depends {
dep, onDisk := issues[d]
var key wire.Key
found := false
if onDisk {
key, found = mapping.RemoteKeyOf(dep)
}
if !found {
key, found = keyOf[d]
}
if !onDisk && !found {
continue
}
out = append(out, pushDep{Slug: d, Key: key, HasKey: found, InRun: pushing[d]})
}
return out
}
// pushLinks turns `depends:` into the tracker's own dependency links.
//
// Topological order means every blocker that is going to have a number has one
// already. The GET is the idempotence check — one request per issue that has
// dependencies at all, and what makes a repeat push a no-op. A failure is a
// warning, never an abort: one missing cross-link must not undo a push that has
// already created issues.
func pushLinks(c *gitea.Client, id string, number int, i *issue.Issue,
issues map[string]*issue.Issue, pushing map[string]bool,
keyOf map[string]wire.Key, repo wire.Repo) error {
var wanted []pushDep
for _, d := range pushDepState(i, issues, pushing, keyOf) {
if d.HasKey && d.Key.Number > 0 {
wanted = append(wanted, d)
}
}
if len(wanted) == 0 {
return nil
}
have, err := c.DependencyKeys(number)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not read the links #%d already has (%v)"+
" — no link was made\n", id, number, err)
return nil
}
for _, d := range wanted {
key := d.Key.In(repo)
if containsKey(have, key) {
continue
}
if err := c.AddDependency(number, key); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: could not link #%d -> %s (%s): %v — link it by "+
"hand, or `kettle pull %d` and push it again\n", id, number, key, d.Slug, err, number)
continue
}
fmt.Printf(" depends on %s (%s)\n", key, d.Slug)
}
return nil
}
// pushPlan is the --dry-run receipt: what would be sent, and which links would
// exist. `#?` is a number this run has not handed out yet.
func pushPlan(root string, issues map[string]*issue.Issue, order []string,
pushing map[string]bool, update bool) {
// The ledger costs no request, so a dry run resolves an already-pushed
// blocker exactly the way the real run does.
keyOf := pushLedgerKeys(loadLedgerOrFold(root, issues), wire.Repo{})
links := 0
for _, id := range order {
i := issues[id]
typ := i.Type()
if typ == "" {
typ = "?"
}
labels := strings.Join(i.Labels, ", ")
if labels == "" {
labels = "no labels"
}
fmt.Printf("ok %s [type/%s] %s (%s)\n", id, typ, i.Title, labels)
for _, d := range pushDepState(i, issues, pushing, keyOf) {
switch {
case d.HasKey:
fmt.Printf(" link -> %s (%s)\n", d.Key, d.Slug)
links++
case d.InRun:
fmt.Printf(" link -> #? (%s, created by this run)\n", d.Slug)
links++
default:
fmt.Printf(" no link: %s is local-only\n", d.Slug)
}
}
}
verb := "created"
if update {
verb = "updated"
}
fmt.Printf("%d issue(s) would be %s, %d dependency link(s) would be created\n",
len(order), verb, links)
}
// pushLedgerKeys is slug -> key, the reverse of the ledger.
//
// Where a dependency's number comes from once push has deleted its file. The
// ledger is keyed by number because that is what a pull has in hand; a push has a
// slug, so it needs the other direction. An entry in the repository being pushed
// to wins when a slug somehow appears under two keys.
func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
raw := make([]string, 0, len(m))
for k := range m {
raw = append(raw, k)
}
sort.Strings(raw)
out := map[string]wire.Key{}
for _, r := range raw {
key, err := wire.ParseKey(r)
if err != nil {
continue
}
slug := m[r]
if _, seen := out[slug]; !seen || key.Repo == repo {
out[slug] = key
}
}
return out
}
// pushConfirmedNumber is the number the tracker confirmed for a write, or ok
// false — the deletion gate.
//
// Every local file this command removes is removed because this returned ok, so
// it is written to be boring and to say no by default: a positive number, and on
// a PATCH the very number that was addressed. What it does not have to catch,
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
// expected, or a `number` that is not a number — the transport fails all three
// before returning, and the file survives by never reaching the delete.
func pushConfirmedNumber(got *wire.Issue, sent int) (int, bool) {
if got == nil || got.Number <= 0 {
return 0, false
}
if sent != 0 && got.Number != sent {
return 0, false
}
return got.Number, true
}
// pushGitBranch is the branch HEAD is on, or "".
//
// The one git call this binary makes — read, never write. A detached HEAD prints
// `HEAD` and outside a repository git exits non-zero; both mean "no branch to
// name", which is not an error.
func pushGitBranch() string {
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return ""
}
name := strings.TrimSpace(string(out))
if name == "HEAD" {
return ""
}
return name
}
func containsKey(keys []wire.Key, want wire.Key) bool {
for _, k := range keys {
if k == want {
return true
}
}
return false
}
+114
View File
@@ -0,0 +1,114 @@
package cmd
import (
"flag"
"fmt"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// labelColumn is how much of the label list a row shows before it is cut.
const labelColumn = 38
func init() {
register(&Command{
Name: "remote",
Group: GroupSync,
Short: "list what exists in the tracker, one line each",
Long: `Discovery only: this prints and WRITES NOTHING. The local store is a store, not a
search-results folder, and a listing that landed in it would leave files nobody
asked for beside the issues somebody did. Pick the numbers here, then pull them.
#42 open type/task, tech/sql Wire sqlc into the repo layer
└─ local: wire-sqlc-appclick
The second line appears when the number is already in the local ledger, so it is
obvious what a pull would refresh and what it would add.
--limit here caps the LISTING: N lines out, closed ones among them. That is not
what the same flag means to ` + "`kettle pull`" + `, and the difference is not an oversight —
pull bounds what it WRITES, this command writes nothing, and enumeration is the
whole job.
Projects are not filterable: the projects API is not exposed by Gitea. Use
milestones or labels, or the web UI.`,
Examples: []Example{
{"kettle remote", "the open issues, 30 of them"},
{"kettle remote --state all --label type/bug --limit 50", "every bug, open and closed"},
{"kettle remote --milestone v0.2", "what is in a milestone"},
{"kettle remote -q sqlc", "keyword search over title and body"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
state := fs.String("state", "open", "open, closed or all")
var labels stringList
fs.Var(&labels, "label", "filter by label; repeat for AND")
// Both spellings, the way the Python this replaces took them.
var query string
fs.StringVar(&query, "q", "", "search text in title and body")
fs.StringVar(&query, "query", "", "the long spelling of -q")
milestone := fs.String("milestone", "", "milestone id or title")
limit := fs.Int("limit", 30, "how many lines to print")
out := storeFlag(fs)
return func(args []string) error {
if len(args) > 0 {
return Fail("remote takes no arguments — filter with --label, --milestone or -q")
}
if !contains([]string{"open", "closed", "all"}, *state) {
return Fail("--state %q must be open, closed or all", *state)
}
if *limit < 1 {
return Fail("--limit must be 1 or more, got %d", *limit)
}
root, client, err := syncStart(*out)
if err != nil {
return err
}
listing, err := client.ListIssues(gitea.IssueFilter{
State: *state, Labels: labels, Query: query,
Milestone: *milestone, Limit: *limit,
})
if err != nil {
return err
}
// The ledger, not the files: a pushed issue has no file left and
// is still something a pull would land on a known slug.
ledger := gitea.LoadRemoteMap(root)
repo := client.Repo()
for i := range listing.Issues {
p := &listing.Issues[i]
labels := "-"
if names := p.LabelNames(); len(names) > 0 {
labels = strings.Join(names, ", ")
}
// One line per issue is the whole point; a repository that
// namespaces heavily would wrap the column otherwise.
if len(labels) > labelColumn {
labels = labels[:labelColumn]
}
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Number, p.State, labels, p.Title)
if local := ledger.Slug(wire.Key{Repo: repo, Number: p.Number}); local != "" {
fmt.Printf("%13s└─ local: %s\n", "", local)
}
}
scope := ""
if listing.Milestone != "" {
scope = " in milestone " + listing.Milestone
}
hint := "<n>"
if *milestone != "" {
hint = "--milestone " + *milestone
}
fmt.Printf("%d issue(s)%s — pull them with: kettle pull %s\n",
len(listing.Issues), scope, hint)
return nil
}
},
})
}
+70
View File
@@ -0,0 +1,70 @@
package cmd
import (
"path/filepath"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
// commentsSidecarPath is where an issue's comment thread lives — beside it,
// under the same slug.
//
// A path, not a concept the domain needs: a thread is pulled from the tracker
// and never pushed back, so `internal/issue` has no reason to learn that the
// file exists. It does not have to — the file is named after the issue, and
// issue.SlugFiles takes it away when the issue goes.
//
// It sits here rather than in either command because `pull` writes it and
// `comment` rewrites it, and two spellings of one path is how the two come to
// disagree about where a thread is.
func commentsSidecarPath(root, id string) string {
return filepath.Join(root, id+".comments.md")
}
// The sync commands all start the same way and must fail the same way.
//
// Every one of them needs a store and a client, and the order matters: a
// command that dialled first would report a network problem for a project that
// was never initialized, and an operator would go looking at the wrong thing.
// So the store is resolved before a socket is opened, and each failure names
// the command that fixes it.
// syncStart resolves the store and builds a client for it.
//
// The client is built from the project's own configuration, which is why there
// is no --login flag anywhere in this tree: which login a project runs under is
// a fact about the project, stated once by `kettle init`, not a thing a caller
// gets to differ about per invocation. That the two could disagree is what the
// Python version needed a PreToolUse hook to police.
func syncStart(out string) (string, *gitea.Client, error) {
root, err := storeRoot(out)
if err != nil {
return "", nil, err
}
cfg, err := config.Require("")
if err != nil {
return "", nil, err
}
client, err := gitea.New(cfg)
if err != nil {
return "", nil, err
}
return root, client, nil
}
// syncStartExisting is syncStart for the commands that read the store rather
// than create it: push, comment, close and the sync form of evict all operate
// on issues that are already on disk, and a missing store is a mistake to
// report, not a directory to conjure.
func syncStartExisting(out string) (string, *gitea.Client, error) {
root, client, err := syncStart(out)
if err != nil {
return "", nil, err
}
if err := issue.RequireStore(root); err != nil {
return "", nil, err
}
return root, client, nil
}
+664
View File
@@ -0,0 +1,664 @@
package cmd_test
// The transport, end to end: the real binary, run as a subprocess against a
// throwaway project, talking to an httptest server that speaks enough of the
// Gitea REST API to answer it.
//
// Enough and no more. What is worth proving here is not that JSON round-trips —
// internal/mapping has tests for that, without a server anywhere — but the two
// rules that cost work when they are wrong: a confirmed push takes the local file
// with it, and an unconfirmed one does not touch it.
//
// The repository is always owner/repo, and the credentials arrive through
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
// overwrite the developer's own tokens.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
type pullFakeGitea struct {
mu sync.Mutex
issues map[int]*wire.Issue
deps map[int][]int
comments map[int][]wire.Comment
labels map[string]int64
next int
// writesFail makes every issue create and edit answer 500 — the failure a
// push has to survive without losing a file.
writesFail bool
}
func pullNewGitea() *pullFakeGitea {
return &pullFakeGitea{
issues: map[int]*wire.Issue{},
deps: map[int][]int{},
comments: map[int][]wire.Comment{},
labels: map[string]int64{},
}
}
// pullAdd puts an issue in the tracker the way the web UI would: it is there
// before this project ever hears about it.
func (g *pullFakeGitea) pullAdd(p wire.Issue) {
g.mu.Lock()
defer g.mu.Unlock()
if p.State == "" {
p.State = "open"
}
p.HTMLURL = pullURL(p.Number)
g.issues[p.Number] = &p
if p.Number > g.next {
g.next = p.Number
}
}
func (g *pullFakeGitea) pullIssue(n int) wire.Issue {
g.mu.Lock()
defer g.mu.Unlock()
if p := g.issues[n]; p != nil {
return *p
}
return wire.Issue{}
}
func (g *pullFakeGitea) pullRetitle(n int, title string) {
g.mu.Lock()
defer g.mu.Unlock()
g.issues[n].Title = title
}
func (g *pullFakeGitea) pullBlocks(blocked int, blockers ...int) {
g.mu.Lock()
defer g.mu.Unlock()
g.deps[blocked] = append(g.deps[blocked], blockers...)
}
func pullURL(n int) string {
return fmt.Sprintf("https://git.example.com/owner/repo/issues/%d", n)
}
var (
pullIssueRoute = regexp.MustCompile(`^issues/(\d+)$`)
pullSubRoute = regexp.MustCompile(`^issues/(\d+)/(dependencies|comments|labels)$`)
)
func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
if !ok {
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
return
}
switch {
case path == "labels" && r.Method == http.MethodGet:
out := []wire.Label{}
for name, id := range g.labels {
out = append(out, wire.Label{ID: id, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
pullJSON(w, out)
case path == "labels" && r.Method == http.MethodPost:
var req wire.LabelRequest
pullDecode(r, &req)
id := int64(1000 + len(g.labels))
g.labels[req.Name] = id
pullJSON(w, wire.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
case path == "milestones" && r.Method == http.MethodGet:
pullJSON(w, []wire.Milestone{})
case path == "issues" && r.Method == http.MethodPost:
if g.writesFail {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
var req wire.IssueRequest
pullDecode(r, &req)
g.next++
p := &wire.Issue{
Number: g.next, Title: pullStr(req.Title), Body: pullStr(req.Body),
State: "open", HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
}
g.issues[p.Number] = p
pullJSON(w, p)
case path == "issues" && r.Method == http.MethodGet:
g.pullList(w, r)
case pullIssueRoute.MatchString(path):
n := pullNumber(pullIssueRoute, path)
p := g.issues[n]
if p == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPatch {
if g.writesFail {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
var req wire.IssueRequest
pullDecode(r, &req)
if req.Title != nil {
p.Title = *req.Title
}
if req.Body != nil {
p.Body = *req.Body
}
if req.State != nil {
p.State = *req.State
}
if req.Labels != nil {
p.Labels = g.pullLabelsFor(req.Labels)
}
}
pullJSON(w, p)
case pullSubRoute.MatchString(path):
m := pullSubRoute.FindStringSubmatch(path)
n, _ := strconv.Atoi(m[1])
switch {
case m[2] == "dependencies" && r.Method == http.MethodGet:
out := []wire.Issue{}
for _, d := range g.deps[n] {
if p := g.issues[d]; p != nil {
out = append(out, *p)
}
}
pullJSON(w, out)
case m[2] == "dependencies" && r.Method == http.MethodPost:
var req struct {
Index int `json:"index"`
}
pullDecode(r, &req)
g.deps[n] = append(g.deps[n], req.Index)
w.WriteHeader(http.StatusCreated)
case m[2] == "comments" && r.Method == http.MethodGet:
out := g.comments[n]
if out == nil {
out = []wire.Comment{}
}
pullJSON(w, out)
case m[2] == "labels" && r.Method == http.MethodPut:
var req struct {
Labels []int64 `json:"labels"`
}
pullDecode(r, &req)
g.issues[n].Labels = g.pullLabelsFor(&req.Labels)
pullJSON(w, g.issues[n].Labels)
default:
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
}
default:
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
}
}
// pullList is the filtered listing, paginated the way the client asks for it.
func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state, page, limit := q.Get("state"), 1, 50
if v, err := strconv.Atoi(q.Get("page")); err == nil && v > 0 {
page = v
}
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 {
limit = v
}
var want []string
if v := q.Get("labels"); v != "" {
want = strings.Split(v, ",")
}
numbers := make([]int, 0, len(g.issues))
for n := range g.issues {
numbers = append(numbers, n)
}
sort.Ints(numbers)
out := []wire.Issue{}
for _, n := range numbers {
p := g.issues[n]
if state != "" && state != "all" && p.State != state {
continue
}
has := map[string]bool{}
for _, l := range p.Labels {
has[l.Name] = true
}
missing := false
for _, l := range want {
missing = missing || !has[l]
}
if missing {
continue
}
out = append(out, *p)
}
start := (page - 1) * limit
if start > len(out) {
start = len(out)
}
end := start + limit
if end > len(out) {
end = len(out)
}
pullJSON(w, out[start:end])
}
func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
if ids == nil {
return nil
}
byID := map[int64]string{}
for name, id := range g.labels {
byID[id] = name
}
var out []wire.Label
for _, id := range *ids {
if name, ok := byID[id]; ok {
out = append(out, wire.Label{ID: id, Name: name})
}
}
return out
}
func pullNumber(re *regexp.Regexp, path string) int {
n, _ := strconv.Atoi(re.FindStringSubmatch(path)[1])
return n
}
func pullDecode(r *http.Request, into any) {
_ = json.NewDecoder(r.Body).Decode(into)
}
func pullJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func pullStr(p *string) string {
if p == nil {
return ""
}
return *p
}
// pullEnv starts the fake and returns the environment that points the binary at
// it. The credential home is a temp directory: a test run may neither read nor
// overwrite the developer's own tokens.
func pullEnv(t *testing.T, g *pullFakeGitea) []string {
t.Helper()
srv := httptest.NewServer(g)
t.Cleanup(srv.Close)
return []string{
config.EnvURL + "=" + srv.URL,
config.EnvToken + "=t0ken",
config.EnvRepo + "=owner/repo",
config.EnvHome + "=" + t.TempDir(),
}
}
func pullStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
func pullRead(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func pullExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// --------------------------------------------------------------------------
// push
// --------------------------------------------------------------------------
// The rule the whole design rests on: once the tracker has the issue, the
// tracker IS the issue, and the local copy goes — sidecars included.
func TestPushCreatesTheIssueAndTakesTheLocalCopyWithIt(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
store := pullStore(dir)
sidecar := filepath.Join(store, id+".comments.md")
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
t.Fatal(err)
}
r := runWith(t, dir, env, "", "push")
if r.code != 0 {
t.Fatalf("push exited %d:\n%s", r.code, r.out())
}
// The number and the URL lead: in a moment they are the only address the
// issue has.
if !strings.Contains(r.stdout, "created "+id+" #1 "+pullURL(1)) {
t.Errorf("the receipt does not say where the issue lives now:\n%s", r.stdout)
}
if pullExists(filepath.Join(store, id+".md")) {
t.Error("the local file survived a confirmed push — what is in the store is what has not left")
}
if pullExists(sidecar) {
t.Error("the sidecar was left behind; every file under the slug goes")
}
// The ledger is what makes the slug come back, so it has to hold the number.
ledger := pullRead(t, filepath.Join(store, ".remote.json"))
if !strings.Contains(ledger, `"owner/repo#1": "`+id+`"`) {
t.Errorf("the ledger does not index the number:\n%s", ledger)
}
// And the slug travelled up in the body, which is what survives a lost ledger.
if body := g.pullIssue(1).Body; !strings.Contains(body, "<!-- kettle:id "+id+" -->") {
t.Errorf("the id marker did not go up with the issue:\n%s", body)
}
if !strings.HasPrefix(g.pullIssue(1).Body, "<!-- kettle:id") {
t.Error("the marker must be the first line of the tracker-side body")
}
}
// Network down, non-2xx, an answer that does not confirm the write: the file
// stays and the run stops. Nothing is deleted that was not just accepted.
func TestPushLeavesTheFileWhenTheTrackerRefuses(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.writesFail = true
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Never made it up")
const id = "never-made-it-up"
path := filepath.Join(pullStore(dir), id+".md")
before := pullRead(t, path)
r := runWith(t, dir, env, "", "push")
if r.code == 0 {
t.Fatalf("a tracker that refuses the write must fail the run:\n%s", r.out())
}
if after := pullRead(t, path); after != before {
t.Errorf("the file was touched by a push that never landed:\n%s", after)
}
// The message has to name the file, because "is my only copy still there" is
// the question an operator has at that moment.
if !strings.Contains(r.stderr, path) {
t.Errorf("the failure does not name the file it did not touch:\n%s", r.stderr)
}
if pullExists(filepath.Join(pullStore(dir), ".remote.json")) {
t.Error("a ledger entry was written for an issue the tracker never confirmed")
}
}
// --------------------------------------------------------------------------
// pull
// --------------------------------------------------------------------------
// A number is an address, not a query. Only filter mode leaves closed issues out.
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{
Number: 7, Title: "Closed but addressable", State: "closed",
Body: "## Summary\nДело сделано.\n", UpdatedAt: "2026-08-01T10:00:00Z",
})
env := pullEnv(t, g)
r := runWith(t, dir, env, "", "pull", "7")
if r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
file := pullRead(t, filepath.Join(pullStore(dir), "closed-but-addressable.md"))
if !strings.Contains(file, "state: closed") {
t.Errorf("the closed state did not land on disk:\n%s", file)
}
if !strings.Contains(file, "gitea: owner/repo#7") {
t.Errorf("the cross-repo handle is missing:\n%s", file)
}
if !strings.Contains(file, "origin: gitea") {
t.Errorf("the issue does not say it exists elsewhere:\n%s", file)
}
}
// A pull answers with the unit of work — the issue and what blocks it — and
// --no-deps is how you ask for one row of it.
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
g := pullNewGitea()
g.pullAdd(wire.Issue{Number: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
g.pullAdd(wire.Issue{Number: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
g.pullBlocks(2, 1)
env := pullEnv(t, g)
t.Run("by default", func(t *testing.T) {
dir := newProject(t)
if r := runWith(t, dir, env, "", "pull", "2"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
t.Fatal("the blocker did not come down — a pull returns the unit of work")
}
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
if !strings.Contains(dependent, "depends: [migrate-the-schema]") {
t.Errorf("depends: was not filled from the tracker's own graph:\n%s", dependent)
}
})
t.Run("--no-deps", func(t *testing.T) {
dir := newProject(t)
if r := runWith(t, dir, env, "", "pull", "2", "--no-deps"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
t.Error("--no-deps followed a blocker anyway")
}
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
if !strings.Contains(dependent, "depends: []") {
t.Errorf("--no-deps filled depends: anyway:\n%s", dependent)
}
})
}
// The round trip, and the two things that carry the slug through it: the ledger,
// and — when the ledger is gone, as it is in a fresh clone — the marker in the
// body. A rename in the web UI changes neither.
func TestAPushedIssueComesBackUnderItsOriginalSlug(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
env := pullEnv(t, g)
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
const id = "wire-sqlc-into-the-appclick-layer"
store := pullStore(dir)
if r := runWith(t, dir, env, "", "push"); r.code != 0 {
t.Fatalf("push exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(store, id+".md")) {
t.Fatal("push did not drop the local copy")
}
g.pullRetitle(1, "Somebody retitled this in the web UI")
// The ledger knows the number, so it wins.
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
file := pullRead(t, filepath.Join(store, id+".md"))
if !strings.Contains(file, "# Somebody retitled this in the web UI") {
t.Errorf("the new title did not come down:\n%s", file)
}
// The marker is transport bookkeeping and never reaches the store.
if strings.Contains(file, "kettle:id") {
t.Errorf("the id marker was written into the local file:\n%s", file)
}
// Now lose both the file and the ledger, the way a fresh clone has neither.
// The marker in the body is all that is left, and it is enough.
for _, p := range []string{filepath.Join(store, id+".md"), filepath.Join(store, ".remote.json")} {
if err := os.Remove(p); err != nil {
t.Fatal(err)
}
}
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(store, id+".md")) {
names, _ := os.ReadDir(store)
var have []string
for _, e := range names {
have = append(have, e.Name())
}
t.Fatalf("the issue came back under another name — every depends: pointing at it now "+
"dangles; the store holds: %s", strings.Join(have, ", "))
}
}
// A closed issue is not a unit of work, so a FILTER enumerates it and leaves it
// out — the exact opposite of what a key does, and only --state closed changes
// it.
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
g := pullNewGitea()
bug := []wire.Label{{ID: 1, Name: "type/bug"}}
g.pullAdd(wire.Issue{Number: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
g.pullAdd(wire.Issue{Number: 2, Title: "Fixed last week", State: "closed",
Body: "## Summary\nx\n", Labels: bug})
env := pullEnv(t, g)
dir := newProject(t)
r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "all")
if r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
t.Error("a filter stored a closed issue")
}
// Nothing is dropped in silence.
if !strings.Contains(r.stderr, "1 closed issue(s) enumerated, not stored") {
t.Errorf("the closed issue went out without a word:\n%s", r.stderr)
}
// Naming the state is how you ask for one.
if r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "closed"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
if !pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
t.Error("--state closed did not store the closed issue")
}
}
// ONE RULE, NO EXCEPTION: a PATCH is a push, and it drops the local copy too.
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{
Number: 3, Title: "Came down and went back up",
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
Labels: []wire.Label{{ID: 1, Name: "type/task"}},
})
env := pullEnv(t, g)
if r := runWith(t, dir, env, "", "pull", "3"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
const id = "came-down-and-went-back-up"
path := filepath.Join(pullStore(dir), id+".md")
if !pullExists(path) {
t.Fatal("the issue did not arrive")
}
r := runWith(t, dir, env, "", "push", "--update", id)
if r.code != 0 {
t.Fatalf("push --update exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "updated "+id+" #3") {
t.Errorf("the receipt does not report the PATCH:\n%s", r.stdout)
}
if pullExists(path) {
t.Error("--update kept the local file — two rules would put back the question " +
"push exists to remove")
}
}
// A dry run makes no request, so it must not need a credential to say what it
// would do — no URL, no token, no repository in the environment at all.
func TestPushDryRunNeedsNoCredential(t *testing.T) {
dir := newProject(t)
mustRun(t, dir, "new", "--type", "task", "--title", "Planned but not sent")
r := run(t, dir, "push", "--dry-run")
if r.code != 0 {
t.Fatalf("a dry run must not need a tracker:\n%s", r.out())
}
if !strings.Contains(r.stdout, "ok planned-but-not-sent") ||
!strings.Contains(r.stdout, "1 issue(s) would be created") {
t.Errorf("the plan was not printed:\n%s", r.stdout)
}
if !pullExists(filepath.Join(pullStore(dir), "planned-but-not-sent.md")) {
t.Error("a dry run deleted the issue")
}
}
// --------------------------------------------------------------------------
// remote
// --------------------------------------------------------------------------
// Discovery writes nothing: the store is a store, not a search-results folder.
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
dir := newProject(t)
g := pullNewGitea()
g.pullAdd(wire.Issue{Number: 4, Title: "Something open", Body: "x"})
g.pullAdd(wire.Issue{Number: 5, Title: "Something closed", State: "closed", Body: "x"})
env := pullEnv(t, g)
r := runWith(t, dir, env, "", "remote")
if r.code != 0 {
t.Fatalf("remote exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "#4") || strings.Contains(r.stdout, "#5") {
t.Errorf("the default listing is the open issues:\n%s", r.stdout)
}
if entries, err := os.ReadDir(pullStore(dir)); err != nil || len(entries) != 0 {
t.Errorf("a listing left files in the store: %v", entries)
}
// A number the store already knows about says so, so it is obvious what a
// pull would refresh and what it would add.
if r := runWith(t, dir, env, "", "pull", "4"); r.code != 0 {
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
}
again := runWith(t, dir, env, "", "remote")
if !strings.Contains(again.stdout, "└─ local: something-open") {
t.Errorf("the local slug was not reported:\n%s", again.stdout)
}
}
+631
View File
@@ -0,0 +1,631 @@
package cmd_test
// The four commands that WRITE — comment, close, labels, sync-evict — end to
// end: the real binary, in a throwaway project, against an httptest server
// speaking enough of the Gitea API to answer them.
//
// A fake tracker rather than a mocked client, because what these commands are
// trusted to get right is exactly the part a mock would stand in for: what goes
// out, and what is believed about the answer. `sync-evict` deletes files on the
// strength of a payload, so the payload has to come off a socket.
//
// Every helper here is named `wr…` so it cannot collide with the read side's.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
const (
wrRepo = "kettle/tests"
wrToken = "s3cr3t-token"
wrWhen = "2026-08-11T12:00:00Z"
)
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
type wrLabel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
}
type wrIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
UpdatedAt string `json:"updated_at"`
}
type wrUser struct {
Login string `json:"login"`
}
type wrComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User wrUser `json:"user"`
CreatedAt string `json:"created_at"`
}
var (
wrIssuePath = regexp.MustCompile(`^issues/(\d+)$`)
wrCommentPath = regexp.MustCompile(`^issues/(\d+)/comments$`)
wrLabelPath = regexp.MustCompile(`^labels/(\d+)$`)
)
// wrTracker is one repository on a pretend Gitea. It records every call, so a
// test can assert that a dry run sent nothing and that a second bootstrap wrote
// nothing.
type wrTracker struct {
mu sync.Mutex
labels []wrLabel
issues map[int]*wrIssue
comments map[int][]wrComment
broken map[int]bool // numbers whose GET answers 500
calls []string
next int64
}
func wrNewTracker() *wrTracker {
return &wrTracker{
issues: map[int]*wrIssue{},
comments: map[int][]wrComment{},
broken: map[int]bool{},
next: 100,
}
}
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
// The scheme Gitea uses and the client sends: the word `token`.
if r.Header.Get("Authorization") != "token "+wrToken {
http.Error(w, `{"message":"token required"}`, http.StatusUnauthorized)
return
}
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/"+wrRepo+"/")
if !ok {
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
return
}
switch {
case path == "labels" && r.Method == http.MethodGet:
wrJSON(w, tr.labels)
case path == "labels" && r.Method == http.MethodPost:
var req wrLabel
wrDecode(r, &req)
tr.next++
req.ID = tr.next
tr.labels = append(tr.labels, req)
wrJSON(w, req)
case r.Method == http.MethodPatch && wrLabelPath.MatchString(path):
id, _ := strconv.ParseInt(wrLabelPath.FindStringSubmatch(path)[1], 10, 64)
var req wrLabel
wrDecode(r, &req)
for i := range tr.labels {
if tr.labels[i].ID == id {
req.ID = id
tr.labels[i] = req
wrJSON(w, req)
return
}
}
http.Error(w, `{"message":"no such label"}`, http.StatusNotFound)
case wrIssuePath.MatchString(path):
n, _ := strconv.Atoi(wrIssuePath.FindStringSubmatch(path)[1])
if tr.broken[n] {
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
return
}
got := tr.issues[n]
if got == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPatch {
var req struct {
State *string `json:"state"`
Title *string `json:"title"`
}
wrDecode(r, &req)
// A close is state and nothing else; a title arriving here would be
// the command editing an issue it was only asked to close.
if req.Title != nil {
http.Error(w, `{"message":"close sent a title"}`, http.StatusUnprocessableEntity)
return
}
if req.State != nil {
got.State = *req.State
}
got.UpdatedAt = wrWhen
}
wrJSON(w, got)
case wrCommentPath.MatchString(path):
n, _ := strconv.Atoi(wrCommentPath.FindStringSubmatch(path)[1])
if tr.issues[n] == nil {
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
return
}
if r.Method == http.MethodPost {
var req struct {
Body string `json:"body"`
}
wrDecode(r, &req)
tr.next++
c := wrComment{
ID: tr.next,
Body: req.Body,
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d#issuecomment-%d", wrRepo, n, tr.next),
User: wrUser{Login: "tester"},
CreatedAt: wrWhen,
}
tr.comments[n] = append(tr.comments[n], c)
wrJSON(w, c)
return
}
wrJSON(w, tr.comments[n])
default:
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
}
}
func wrJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func wrDecode(r *http.Request, v any) {
_ = json.NewDecoder(r.Body).Decode(v)
}
// --- what the tracker holds, for a test to arrange and to read back ---------
func (tr *wrTracker) add(number int, title, state string) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.issues[number] = &wrIssue{
Number: number,
Title: title,
State: state,
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d", wrRepo, number),
UpdatedAt: wrWhen,
}
}
func (tr *wrTracker) breaks(number int) {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.broken[number] = true
}
func (tr *wrTracker) state(number int) string {
tr.mu.Lock()
defer tr.mu.Unlock()
if got := tr.issues[number]; got != nil {
return got.State
}
return ""
}
func (tr *wrTracker) label(name string) *wrLabel {
tr.mu.Lock()
defer tr.mu.Unlock()
for i := range tr.labels {
if tr.labels[i].Name == name {
out := tr.labels[i]
return &out
}
}
return nil
}
func (tr *wrTracker) labelCount() int {
tr.mu.Lock()
defer tr.mu.Unlock()
return len(tr.labels)
}
func (tr *wrTracker) thread(number int) []wrComment {
tr.mu.Lock()
defer tr.mu.Unlock()
return append([]wrComment{}, tr.comments[number]...)
}
// mark is where the log has got to, so a test can ask what one run sent.
func (tr *wrTracker) mark() int {
tr.mu.Lock()
defer tr.mu.Unlock()
return len(tr.calls)
}
func (tr *wrTracker) since(mark int) []string {
tr.mu.Lock()
defer tr.mu.Unlock()
return append([]string{}, tr.calls[mark:]...)
}
func (tr *wrTracker) count(method string) int {
tr.mu.Lock()
defer tr.mu.Unlock()
n := 0
for _, c := range tr.calls {
if strings.HasPrefix(c, method+" ") {
n++
}
}
return n
}
// --------------------------------------------------------------------------
// the fixture
// --------------------------------------------------------------------------
// wrProject is an initialized project pointed at a fake tracker.
//
// The credentials arrive through the environment, which is what they are there
// for — and KETTLE_CONFIG_HOME goes at a temp directory so a run can neither
// read nor overwrite the developer's own tokens. KETTLE_LOGIN is cleared for the
// same reason: a value in the developer's shell would send every fixture
// looking for a login that is not in the temp file.
func wrProject(t *testing.T) (dir string, tr *wrTracker, env []string) {
t.Helper()
dir = newProject(t)
tr = wrNewTracker()
srv := httptest.NewServer(tr)
t.Cleanup(srv.Close)
return dir, tr, []string{
"KETTLE_URL=" + srv.URL,
"KETTLE_TOKEN=" + wrToken,
"KETTLE_REPO=" + wrRepo,
"KETTLE_CONFIG_HOME=" + t.TempDir(),
"KETTLE_LOGIN=",
}
}
func wrStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
// wrTracked writes an issue the tracker also holds — a working copy, as a pull
// would have left it.
func wrTracked(t *testing.T, dir, id string, number int, state string) string {
t.Helper()
return wrWrite(t, dir, id, fmt.Sprintf(
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
"origin: gitea\ngitea: %s#%d\nsynced: 2026-01-01T00:00:00Z\nurl: https://tracker.example/%s/issues/%d\n---\n"+
"# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
id, state, wrRepo, number, wrRepo, number, id))
}
// wrLocal writes an `origin: local` issue — the only copy of that work.
func wrLocal(t *testing.T, dir, id, state string) string {
t.Helper()
return wrWrite(t, dir, id, fmt.Sprintf(
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
"origin: local\n---\n# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
id, state, id))
}
func wrWrite(t *testing.T, dir, id, text string) string {
t.Helper()
path := filepath.Join(wrStore(dir), id+".md")
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func wrRead(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func wrGone(t *testing.T, path, why string) {
t.Helper()
if _, err := os.Stat(path); err == nil {
t.Errorf("%s is still there — %s", filepath.Base(path), why)
}
}
func wrThere(t *testing.T, path, why string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("%s is gone — %s", filepath.Base(path), why)
}
}
// --------------------------------------------------------------------------
// labels
// --------------------------------------------------------------------------
func TestLabelsCreatesTheCanonicalSetAndThenChangesNothing(t *testing.T) {
dir, tr, env := wrProject(t)
r := runWith(t, dir, env, "", "labels")
if r.code != 0 {
t.Fatalf("labels exited %d:\n%s", r.code, r.out())
}
// The set is the domain's, name for name: nothing is spelled out in the
// command, so adding a type over there is what adds it here.
want := issue.CanonicalLabels()
if got := tr.labelCount(); got != len(want) {
t.Fatalf("the repository holds %d label(s), want %d:\n%s", got, len(want), r.out())
}
for _, name := range want {
l := tr.label(name)
if l == nil {
t.Fatalf("%s was not created:\n%s", name, r.out())
}
if l.Color == "" {
t.Errorf("%s was created with no colour", name)
}
// `exclusive` is the flag no tracker CLI could set, and the whole reason
// label creation goes through the API.
if !l.Exclusive {
t.Errorf("%s is not exclusive", name)
}
}
if !strings.Contains(r.stdout, "created type/bug") {
t.Errorf("the receipt does not name what it created:\n%s", r.stdout)
}
// A label belongs to the repository, not to any issue: this must not have
// touched the store.
if _, err := os.Stat(filepath.Join(wrStore(dir), "INDEX.md")); err == nil {
t.Error("a label bootstrap wrote into the issue store")
}
mark := tr.mark()
again := runWith(t, dir, env, "", "labels")
if again.code != 0 {
t.Fatalf("the second run exited %d:\n%s", again.code, again.out())
}
for _, c := range tr.since(mark) {
if !strings.HasPrefix(c, "GET ") {
t.Errorf("the second run wrote: %s", c)
}
}
if !strings.Contains(again.stdout, "present type/bug") ||
!strings.Contains(again.stdout, "0 created") {
t.Errorf("a second run must be a no-op and say so:\n%s", again.stdout)
}
}
// --------------------------------------------------------------------------
// close
// --------------------------------------------------------------------------
func TestCloseChangesTheStateOnTheTrackerAndOnDisk(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Done and elsewhere", "open")
tr.add(99, "Never seen here", "open")
path := wrTracked(t, dir, "done-and-elsewhere", 42, "open")
dry := runWith(t, dir, env, "", "close", "--dry-run", "done-and-elsewhere")
if dry.code != 0 || !strings.Contains(dry.stdout, "would close") {
t.Fatalf("the dry run said nothing:\n%s", dry.out())
}
if n := tr.count("PATCH"); n != 0 {
t.Errorf("a dry run sent %d write(s) — it must make no request at all", n)
}
if !strings.Contains(wrRead(t, path), "state: open") {
t.Error("a dry run wrote to the local file")
}
r := runWith(t, dir, env, "", "close", "done-and-elsewhere")
if r.code != 0 {
t.Fatalf("close exited %d:\n%s", r.code, r.out())
}
if got := tr.state(42); got != "closed" {
t.Errorf("the tracker says %q, want closed", got)
}
local := wrRead(t, path)
if !strings.Contains(local, "state: closed") {
t.Errorf("the local copy was not brought along:\n%s", local)
}
// The answer that authorized the write is also the newest thing the tracker
// has said, so the freshness fields are stamped from it.
if !strings.Contains(local, "remote-updated: "+wrWhen) || !strings.Contains(local, "synced: 20") {
t.Errorf("the freshness fields were not stamped:\n%s", local)
}
index := filepath.Join(wrStore(dir), "INDEX.md")
if !strings.Contains(wrRead(t, index), "closed") {
t.Error("INDEX.md was not rebuilt from what is now on disk")
}
// A number this machine has never seen: closed in the tracker, nothing
// written here, and the receipt says which is which.
byNumber := runWith(t, dir, env, "", "close", "99")
if byNumber.code != 0 {
t.Fatalf("closing by number exited %d:\n%s", byNumber.code, byNumber.out())
}
if got := tr.state(99); got != "closed" {
t.Errorf("#99 says %q, want closed", got)
}
if !strings.Contains(byNumber.stdout, "no local copy") {
t.Errorf("the receipt hid that there was nothing to write:\n%s", byNumber.stdout)
}
// A number resolves through the file that carries the handle, so the local
// copy of #42 is kept honest even when it was named by number.
back := runWith(t, dir, env, "", "close", "--reopen", "42")
if back.code != 0 {
t.Fatalf("reopening by number exited %d:\n%s", back.code, back.out())
}
if got := tr.state(42); got != "open" {
t.Errorf("#42 says %q, want open", got)
}
if !strings.Contains(wrRead(t, path), "state: open") {
t.Errorf("a number named the tracker but not the local copy holding its handle:\n%s", wrRead(t, path))
}
// An issue that has never left this machine has no state in the tracker to
// change, and saying so beats editing one field of a local file.
wrLocal(t, dir, "never-left-here", "open")
refused := runWith(t, dir, env, "", "close", "never-left-here")
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
t.Errorf("closing a local issue must stop and say why:\n%s", refused.out())
}
}
// --------------------------------------------------------------------------
// comment
// --------------------------------------------------------------------------
func TestCommentPostsAndTheThreadLandsBesideTheIssue(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Talk about it", "open")
wrTracked(t, dir, "talk-about-it", 42, "open")
wrLocal(t, dir, "never-left-here", "open")
const said = "готово, задеплоено"
r := runWith(t, dir, env, "", "comment", "talk-about-it", "--body", said)
if r.code != 0 {
t.Fatalf("comment exited %d:\n%s", r.code, r.out())
}
thread := tr.thread(42)
if len(thread) != 1 || thread[0].Body != said {
t.Fatalf("the tracker holds %v", thread)
}
sidecar := filepath.Join(wrStore(dir), "talk-about-it.comments.md")
got := wrRead(t, sidecar)
if !strings.Contains(got, said) || !strings.Contains(got, "## comment ") {
t.Errorf("the thread did not land beside the issue:\n%s", got)
}
if !strings.Contains(r.stdout, "posted comment") || !strings.Contains(r.stdout, "thread:") {
t.Errorf("the receipt does not say what happened:\n%s", r.stdout)
}
// The target is a local id resolved through the `gitea:` handle, so an issue
// that carries none cannot be commented on at all.
refused := runWith(t, dir, env, "", "comment", "never-left-here", "--body", "x")
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
t.Errorf("commenting on a local-only issue must stop and say why:\n%s", refused.out())
}
if n := tr.count("POST"); n != 1 {
t.Errorf("%d comment(s) went out, want 1 — the refused one was sent anyway", n)
}
}
// --------------------------------------------------------------------------
// sync-evict
// --------------------------------------------------------------------------
// The one thing this command adds to the offline evict: a `state:` that is not
// stale. The file says open, the tracker says closed, and the tracker is right.
func TestSyncEvictRefreshesTheStateBeforeItDecides(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Closed in the web ui", "closed")
path := wrTracked(t, dir, "closed-in-the-web-ui", 42, "open")
offline := runWith(t, dir, env, "", "evict")
if offline.code != 0 || !strings.Contains(offline.stdout, "0 issue(s) evicted") {
t.Fatalf("the offline evict must keep an issue whose file reads open:\n%s", offline.out())
}
wrThere(t, path, "the offline evict asks the file, and the file says open")
// A dry run asks, reports, and touches nothing.
dry := runWith(t, dir, env, "", "sync-evict", "--dry-run")
if dry.code != 0 || !strings.Contains(dry.stdout, "would evict") {
t.Fatalf("the dry run said nothing:\n%s", dry.out())
}
wrThere(t, path, "a dry run deleted the issue")
if !strings.Contains(wrRead(t, path), "state: open") {
t.Error("a dry run wrote the refreshed state to disk")
}
r := runWith(t, dir, env, "", "sync-evict")
if r.code != 0 {
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "open -> closed") {
t.Errorf("the refresh was not reported:\n%s", r.stdout)
}
wrGone(t, path, "the tracker said it was closed")
if index := wrRead(t, filepath.Join(wrStore(dir), "INDEX.md")); strings.Contains(index, "closed-in-the-web-ui") {
t.Errorf("INDEX.md still lists the evicted issue:\n%s", index)
}
}
func TestSyncEvictKeepsALocalIssueTheTrackerNeverHeardOf(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "Done elsewhere", "closed")
tracked := wrTracked(t, dir, "done-elsewhere", 42, "closed")
local := wrLocal(t, dir, "only-copy-there-is", "closed")
r := runWith(t, dir, env, "", "sync-evict")
if r.code != 0 {
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
}
wrThere(t, local, "a closed origin: local issue was deleted, and that file IS the work")
wrGone(t, tracked, "it is closed and the tracker has it")
// It was never asked about either: an issue that has never left this machine
// is not a question the tracker has an answer to.
if n := tr.count("GET"); n != 1 {
t.Errorf("%d issue(s) were asked about, want 1", n)
}
// Naming it explicitly does not make deleting it safe, and the reason is
// said out loud rather than left to be inferred from silence.
named := runWith(t, dir, env, "", "sync-evict", "only-copy-there-is")
if named.code != 0 {
t.Fatalf("naming a local issue exited %d:\n%s", named.code, named.out())
}
if !strings.Contains(named.stdout, "kept") || !strings.Contains(named.stdout, "IS the issue") {
t.Errorf("keeping it must be said out loud:\n%s", named.out())
}
wrThere(t, local, "naming it on the command line deleted it")
}
// A failed answer evicts NOTHING AT ALL — not even the issues whose answers had
// already arrived. There is no ordering constraint between evictions, so there
// is no reason to start before every answer is in.
func TestATrackerFailureDuringSyncEvictEvictsNothing(t *testing.T) {
dir, tr, env := wrProject(t)
tr.add(42, "First answer", "closed")
tr.add(43, "Second answer", "closed")
tr.breaks(43)
answered := wrTracked(t, dir, "aaa-answered", 42, "closed")
unanswered := wrTracked(t, dir, "bbb-unanswered", 43, "closed")
r := runWith(t, dir, env, "", "sync-evict")
if r.code == 0 {
t.Fatalf("a tracker failure must stop the run:\n%s", r.out())
}
if !strings.Contains(r.stderr, "Nothing was evicted") {
t.Errorf("the failure must say what it did not do:\n%s", r.stderr)
}
wrThere(t, answered, "its answer arrived, but another one did not")
wrThere(t, unanswered, "the tracker never answered for it")
}
+164
View File
@@ -0,0 +1,164 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "tree",
Group: GroupIssue,
Args: "[<id>…]",
Short: "draw the dependency graph of the local store",
Long: `Edges come from the ` + "`depends:`" + ` metadata, which is the authoritative edge list;
prose in the body is never walked. Because the graph is slugs all the way down,
this works identically for issues that were never pushed anywhere.
Downwards is what this draws — what an issue depends on. The other direction is
a grep, not a flag:
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md`,
Examples: []Example{
{"kettle tree", "every root (nothing depends on it)"},
{"kettle tree wire-sqlc-appclick", "one subtree"},
{"kettle tree --depth 2 --write", "shallow, and saved beside the issues"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
depth := fs.Int("depth", 6, "maximum depth")
write := fs.Bool("write", false, "also write <store>/tree-<slug>.md")
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
if err := issue.StoreError(root); err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
edges := issue.Graph(issues)
roots := args
for _, r := range roots {
if _, ok := issues[r]; !ok {
return Fail("no issue %q in %s", r, root)
}
}
if len(roots) == 0 {
dependedOn := map[string]bool{}
for _, deps := range edges {
for _, d := range deps {
dependedOn[d] = true
}
}
for id := range issues {
if !dependedOn[id] {
roots = append(roots, id)
}
}
if len(roots) == 0 { // every issue is somebody's dependency
for id := range issues {
roots = append(roots, id)
}
}
sort.Strings(roots)
}
text := renderTree(roots, issues, edges, *depth)
fmt.Print(text)
if *write {
slug := "all"
if len(roots) == 1 {
slug = roots[0]
}
path := filepath.Join(root, "tree-"+slug+".md")
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
return err
}
fmt.Printf("written: %s\n", path)
}
return nil
}
},
})
}
func renderTree(roots []string, issues map[string]*issue.Issue, edges map[string][]string, depth int) string {
var lines []string
seen := map[string]bool{}
var walk func(id, prefix string, isLast, isRoot bool, level int)
walk = func(id, prefix string, isLast, isRoot bool, level int) {
connector := ""
if !isRoot {
connector = "├── "
if isLast {
connector = "└── "
}
}
lines = append(lines, prefix+connector+treeLabel(id, issues, seen, edges))
if seen[id] || level >= depth {
return
}
seen[id] = true
kids := edges[id]
childPrefix := prefix
if !isRoot {
childPrefix = prefix + "│ "
if isLast {
childPrefix = prefix + " "
}
}
for i, k := range kids {
walk(k, childPrefix, i == len(kids)-1, false, level+1)
}
}
for _, r := range roots {
if seen[r] {
continue // already drawn as somebody's child — one tree, not two
}
walk(r, "", true, true, 0)
lines = append(lines, "")
}
head := fmt.Sprintf("%d root(s)", len(roots))
if len(roots) == 1 {
head = roots[0]
}
out := fmt.Sprintf("# Dependency tree — %s\n\n```\n%s```\n", head, strings.Join(lines, "\n"))
if cycles := issue.FindCycles(edges); len(cycles) > 0 {
out += "\n## Cycles\n\n"
for _, c := range cycles {
out += "- " + strings.Join(c, " -> ") + "\n"
}
}
return out
}
func treeLabel(id string, issues map[string]*issue.Issue, seen map[string]bool, edges map[string][]string) string {
i, ok := issues[id]
if !ok {
return id + " (not in the store)"
}
tail := ""
if seen[id] && len(edges[id]) > 0 {
tail = " (see above)"
}
typ := i.Type()
if typ == "" {
typ = "-"
}
return fmt.Sprintf("%s [%s] %s — %s %s.md%s", id, typ, i.Title, i.State, id, tail)
}