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:
@@ -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"))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user