diff --git a/cli/AGENTS.md b/cli/AGENTS.md new file mode 100644 index 0000000..8522d81 --- /dev/null +++ b/cli/AGENTS.md @@ -0,0 +1,233 @@ +# AGENTS.md — the kettle CLI + +`kettle` is a globally installed binary. It owns everything that used to be a +Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the +store lives, who this machine is, and (once the transport lands) how issues move +to and from Gitea. + +The plugin keeps what only a plugin can carry — the rules an operator states and +a binary cannot enforce. Everything mechanical is here. + +## Why a binary + +Three failures in the Python version were failures of *runtime*, not of logic: + +- the store resolved from `__file__`, so it landed inside a versioned plugin + cache and issues written from one project were invisible from the next; +- the walk that answers "which directory is the project" was written three + times — store, login pin, guard hook — and in a linked worktree the three + disagreed; +- `sys.path.insert` was the import mechanism, so the layering rule was a + convention checked by grep. + +A compiled binary answers all three by construction. There is one walk +(`internal/project`), it is imported rather than re-derived, and the layering +rule is a build graph a test walks. + +## One dependency + +`gopkg.in/yaml.v3`, vendored, and that is the whole list. Everything else is the +standard library: the transport is plain `net/http` against a documented REST +API, and the CLI has no cobra — commands are values in a registry, which is what +lets the plugin's SKILL.md files be generated from the same struct that holds +the code. + +`vendor/` is committed, so a build needs no network. + +## Layers + +Knowledge flows one way. The arrow means "imports". + +``` +cmd/kettle thin main; exit status only + +internal/cmd the command tree: flags, receipts, exit codes + │ │ │ + │ │ └────► internal/config who this machine is, what this + │ │ project points at; yaml lives here + │ │ and only here + │ └───────────► internal/gitea TRANSPORT: one door for every + │ │ request, pagination, payload dumps, + │ │ the number -> slug ledger + │ ▼ + ├────────────────────► internal/wire PROTOCOL: the JSON shapes and the + │ ▲ identifiers. Imports nothing. + │ │ + └──► internal/mapping ─────┘ BRIDGE: md <-> those shapes, pure, + │ no I/O; label colours live here + ▼ +internal/issue DOMAIN what an issue is: format, taxonomy, validation, + │ checkboxes, dependency graph, the store, eviction + │ offline — no tracker, no network, no JSON + ▼ +internal/project ROOT which directory is the project, and every path + resolved from it: store, payload, config + depends on nothing +``` + +Read it bottom-up and each layer knows strictly less about trackers than the one +above it. Four tests hold the line, and each fails on a real mistake rather than +on a naming convention: + +- `internal/issue` may import `internal/project` and the standard library, and + nothing else. One test walks `go list -deps` and fails on any path with a dot + in its first element — which is also what keeps yaml out of the domain — and + another names `net/http`, `net`, `os/exec` and `encoding/json`, standard + library the first test would not catch. +- `internal/wire` imports only the standard library, checked the same two ways. +- `internal/gitea` must not import `internal/issue` **or** `internal/mapping`: + the transport knows numbers, logins, HTTP and JSON, and none of what they mean. +- `internal/mapping` performs no I/O and imports neither the transport nor the + configuration. + +`wire` exists because Go needs the JSON shapes to be one type. The transport and +the bridge were written in parallel and each invented its own `Issue`, `Label`, +`Milestone` and `Comment`; every command on top would then have copied fields +from one struct into the other by hand, which is two vocabularies for one thing — +exactly what this layering exists to prevent. Python did not have the problem +because it passed dicts. + +If a tracker concept — an issue number, a login, an HTTP call, a label colour — +shows up in `internal/issue`, it is in the wrong place. + +## The walk + +`internal/project` answers one question and everything else reads the answer. + +Anchors, first hit wins: `$CLAUDE_PROJECT_DIR`, then the working directory. Each +is searched up its parent chain for a `.kettle/` marker, and then — only if that +found nothing — up the parent chain of the **main working tree of any linked +worktree** met on the way, reached by reading `gitdir:` out of a `.git` *file* +and following `commondir`. + +Nothing here resolves from the executable's own location. Where an installation +keeps its files is a fact about the installation; whose issues a tree has is a +fact about the tree, and a binary installed in one place and pointed at another +must answer from the one it was pointed at. + +**The marker is created by `kettle init`, never inferred.** `.git` was tried and +is in every clone. No marker anywhere is an answer, not a fallback: the command +reports which directories it searched and stops. + +## Configuration, and where secrets are not + +Two files, and the split is the whole design. + +`/.kettle/config.yaml` — written by `kettle init`, says which tracker +repository the issues belong to and which login to reach it under. It 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 a working tree. + +`~/.config/kettle/logins.yaml` (`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME` +override it) — one file per machine, mode 0600, holding the tokens. Managed with +`kettle auth`, which reads the token from standard input by default because an +argument is in the shell history the moment it is typed. Nothing prints a token +back: `kettle config` shows `(set)`. + +A token in a file inside a working tree ends up in a commit. Not always, not +immediately, and not by anyone careless — but a project config is exactly the +file somebody eventually decides to share, and a secret that has ever been +pushed has to be rotated. + +`KETTLE_LOGIN`, `KETTLE_REPO`, `KETTLE_URL` and `KETTLE_TOKEN` each override the +file they shadow, for CI and for anyone who would rather have no token on disk. + +Unknown keys in either file are an **error**, not a silent drop: an older binary +reading a newer config would otherwise delete the setting it did not recognize +the next time it wrote the file. + +`init` gitignores `.kettle/` wholesale. An `origin: local` issue is the only copy +of that work, and what goes into a shared history is the operator's call, not +this binary's — drop the line if the team decides otherwise. + +## The store + +`/.kettle/issues`, one flat markdown file per issue, named by its +slug, one metadata field per line so plain grep works without a parser. + +It holds two kinds of file and only one of them is a store. An `origin: local` +issue lives here and nowhere else — that file **is** the issue. Anything with a +tracker origin is a working copy, deleted the moment a push confirms the tracker +is up to date. Eviction makes the same trade one step earlier and asks the file +instead of the tracker, which is why it lives in the domain. + +`.kettle/payload/` is a sibling, never a child: request bodies are debris of the +transport, and a scratchpad inside a store makes `ls .kettle/issues` lie about +what exists. + +`kettle init` migrates older layouts in, oldest first — `tmp/issues` and then +`.tea/issues` — and each is a **move**. A store left behind at an old path is a +store somebody will edit by accident months later. It refuses to pick a winner +when both sides hold a file of the same name. + +## Tests + +```bash +cd cli && go test ./... +``` + +`internal/cmd` builds the binary once in `TestMain` and runs it as a subprocess +against a throwaway project in a temp directory — the same discipline the Python +suite arrived at, for the same reason. **The binary is never run in the +directory it was built in**, because that is exactly the arrangement that hid +the `__file__` bug: a tool is installed in one place and used on projects in +another, and a test that collapses the two proves nothing about resolution. + +Every fixture strips `CLAUDE_PROJECT_DIR`. It is the first anchor of the walk, +so the harness's own value would point every fixture at this repository. +Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so +a test run can neither read nor overwrite the developer's own tokens. + +## The round trip + +`push` and `pull` are the two halves of one rule, and the rule is that **the +store holds what has not left this machine.** + +A successful push deletes `.md` and every sidecar under that slug, on create +and on `--update` alike, and prints the number and URL the issue now lives at. +The deletion happens **only after a confirmed tracker response and only after the +number -> slug ledger has been written** — network down, non-2xx, or an answer +that does not carry the right number, and the file stays where it is while the +run stops. A never-pushed `origin: local` issue is never touched by any of it. + +The slug survives that round trip two ways over, and a test proves both: it goes +up in the body as ``, and it is indexed by number in +`.kettle/issues/.remote.json`. A rename in the web UI, a lost ledger, a fresh +clone, another machine — the file comes back under the same name and every +`depends:` pointing at it still resolves. The marker is written in that spelling +and read in both it and the older ``, because issues pushed +before the rename are still in the tracker. + +Pull by number fetches an issue in **any** state: a number is an address, not a +query, and `42`, `#42`, `owner/repo#42` and a URL all name one. Only filter mode +leaves closed issues out. A pull returns the unit of work rather than one row of +it — blockers come down with it unless `--no-deps` says otherwise — and it +overwrites the body, because it is a fetch and not a merge. The one exception is +checkbox state, which is the local half of the work and is merged rather than +clobbered. + +## No guard hook + +The Python version needed a `PreToolUse` hook to block any `tea` command that +would run under a login the model picked instead of the operator. That whole +apparatus is gone. The binary holds its own credentials and reads the login out +of the project's own configuration, so there is no argument to police and no way +for the transport and the guard to disagree — the failure the hook existed to +catch is not expressible any more. + +There is also no `--login` and no `--repo` on any sync command bar `labels`. +Which login a project runs under, and which repository its issues belong to, are +facts about the project, stated once by `kettle init`. A cross-repository address +is still an address: `kettle pull owner/repo#42` re-points the client for that +one call. + +## Status + +Done and tested: all seven packages, and the commands `init`, `auth`, `config`, +`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`, +`comment`, `close`, `labels`, `sync-evict`. 89 tests. + +Not done: the plugin still ships the Python scripts and the guard hook, and +still resolves `.tea/`. Rewiring `plugins/tea` onto this binary — and generating +its SKILL.md files from the command registry, so the docs cannot drift from the +CLI — is the remaining work. diff --git a/cli/cmd/kettle/main.go b/cli/cmd/kettle/main.go new file mode 100644 index 0000000..04ba234 --- /dev/null +++ b/cli/cmd/kettle/main.go @@ -0,0 +1,14 @@ +// Command kettle keeps a project's issues as local markdown and moves them to +// and from a tracker. +// +// An issue is a unit of work first and a tracker row second. Everything under +// `kettle help` that is not in the sync group works with the network off. +package main + +import ( + "os" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/cmd" +) + +func main() { os.Exit(cmd.Main(os.Args[1:])) } diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..2d28d83 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,5 @@ +module git.noodles.cam/claude-skills/marketplace/cli + +go 1.24 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/cmd/ac.go b/cli/internal/cmd/ac.go new file mode 100644 index 0000000..ce78355 --- /dev/null +++ b/cli/internal/cmd/ac.go @@ -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: "", + 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")) +} diff --git a/cli/internal/cmd/auth.go b/cli/internal/cmd/auth.go new file mode 100644 index 0000000..09baab6 --- /dev/null +++ b/cli/internal/cmd/auth.go @@ -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 ", + 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() +} diff --git a/cli/internal/cmd/check.go b/cli/internal/cmd/check.go new file mode 100644 index 0000000..44b724d --- /dev/null +++ b/cli/internal/cmd/check.go @@ -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: "[…]", + 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 + } + }, + }) +} diff --git a/cli/internal/cmd/cli_test.go b/cli/internal/cmd/cli_test.go new file mode 100644 index 0000000..6c81f7f --- /dev/null +++ b/cli/internal/cmd/cli_test.go @@ -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 --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 +} diff --git a/cli/internal/cmd/close.go b/cli/internal/cmd/close.go new file mode 100644 index 0000000..f502bf9 --- /dev/null +++ b/cli/internal/cmd/close.go @@ -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: " […]", + 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 +} diff --git a/cli/internal/cmd/command.go b/cli/internal/cmd/command.go new file mode 100644 index 0000000..9c61a3a --- /dev/null +++ b/cli/internal/cmd/command.go @@ -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. " […]". + Args string + // Short is one line, shown in the command list. + Short string + // Long is the full explanation, shown by `kettle help `. + 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 [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 ` 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 --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 } diff --git a/cli/internal/cmd/comment.go b/cli/internal/cmd/comment.go new file mode 100644 index 0000000..abcbdf7 --- /dev/null +++ b/cli/internal/cmd/comment.go @@ -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: "", + 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 ` + "`.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 +` + "`.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 , 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 +} diff --git a/cli/internal/cmd/config.go b/cli/internal/cmd/config.go new file mode 100644 index 0000000..095aeed --- /dev/null +++ b/cli/internal/cmd/config.go @@ -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 + } + }, + }) +} diff --git a/cli/internal/cmd/evict.go b/cli/internal/cmd/evict.go new file mode 100644 index 0000000..f2831da --- /dev/null +++ b/cli/internal/cmd/evict.go @@ -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: "[…]", + 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: 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) + } +} diff --git a/cli/internal/cmd/evict_sync.go b/cli/internal/cmd/evict_sync.go new file mode 100644 index 0000000..c667e6f --- /dev/null +++ b/cli/internal/cmd/evict_sync.go @@ -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: "[…]", + 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 +} diff --git a/cli/internal/cmd/flags.go b/cli/internal/cmd/flags.go new file mode 100644 index 0000000..bef8a71 --- /dev/null +++ b/cli/internal/cmd/flags.go @@ -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: /.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 +} diff --git a/cli/internal/cmd/gen.go b/cli/internal/cmd/gen.go new file mode 100644 index 0000000..53f02cd --- /dev/null +++ b/cli/internal/cmd/gen.go @@ -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 = "" + genClose = "" +) + +// 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 /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 `") + } + 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) +} diff --git a/cli/internal/cmd/gen_test.go b/cli/internal/cmd/gen_test.go new file mode 100644 index 0000000..d07962e --- /dev/null +++ b/cli/internal/cmd/gen_test.go @@ -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 = "" + genClose = "" +) + +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 […]`", + "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") +} diff --git a/cli/internal/cmd/index.go b/cli/internal/cmd/index.go new file mode 100644 index 0000000..1842d9b --- /dev/null +++ b/cli/internal/cmd/index.go @@ -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 + } + }, + }) +} diff --git a/cli/internal/cmd/init.go b/cli/internal/cmd/init.go new file mode 100644 index 0000000..ae3bee4 --- /dev/null +++ b/cli/internal/cmd/init.go @@ -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 + } + }, + }) +} diff --git a/cli/internal/cmd/labels.go b/cli/internal/cmd/labels.go new file mode 100644 index 0000000..5e40505 --- /dev/null +++ b/cli/internal/cmd/labels.go @@ -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, ", ") +} diff --git a/cli/internal/cmd/new.go b/cli/internal/cmd/new.go new file mode 100644 index 0000000..30e08af --- /dev/null +++ b/cli/internal/cmd/new.go @@ -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/.md prefilled with the type's template, prints the +path, and rebuilds INDEX.md. Fill the sections in an editor, then run +` + "`kettle check `" + `. + +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 +} diff --git a/cli/internal/cmd/pull.go b/cli/internal/cmd/pull.go new file mode 100644 index 0000000..5d443fe --- /dev/null +++ b/cli/internal/cmd/pull.go @@ -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: "[…]", + 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 + 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 .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 .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() +} diff --git a/cli/internal/cmd/push.go b/cli/internal/cmd/push.go new file mode 100644 index 0000000..dcdf385 --- /dev/null +++ b/cli/internal/cmd/push.go @@ -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: "[…]", + Short: "send local issues to the tracker; the local copy goes with them", + Long: `A SUCCESSFUL PUSH DELETES THE LOCAL FILE — .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 `" + `, which returns it under +the same slug, because the slug travelled up in the body as +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 ` 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 +} diff --git a/cli/internal/cmd/remote.go b/cli/internal/cmd/remote.go new file mode 100644 index 0000000..8d9b777 --- /dev/null +++ b/cli/internal/cmd/remote.go @@ -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 := "" + 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 + } + }, + }) +} diff --git a/cli/internal/cmd/sync.go b/cli/internal/cmd/sync.go new file mode 100644 index 0000000..5dfc5b1 --- /dev/null +++ b/cli/internal/cmd/sync.go @@ -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 +} diff --git a/cli/internal/cmd/sync_pull_test.go b/cli/internal/cmd/sync_pull_test.go new file mode 100644 index 0000000..ef76ef9 --- /dev/null +++ b/cli/internal/cmd/sync_pull_test.go @@ -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, "") { + t.Errorf("the id marker did not go up with the issue:\n%s", body) + } + if !strings.HasPrefix(g.pullIssue(1).Body, "` markers and prose full of `&`. + // Escaping those to < would make the dump unreadable exactly when + // somebody is reading it because something went wrong. + enc.SetEscapeHTML(false) + if err := enc.Encode(b.Data); err != nil { + return nil, fmt.Errorf("encoding the %s request body: %w", b.name(), err) + } + raw := buf.Bytes() + + if err := os.MkdirAll(c.payloadRoot, 0o755); err != nil { + return nil, err + } + path := filepath.Join(c.payloadRoot, b.name()+".json") + if err := os.WriteFile(path, raw, 0o644); err != nil { + return nil, err + } + return raw, nil +} + +// name is the file stem, with everything that is not plainly a file name folded +// away. +// +// Sanitizing here rather than trusting callers: label names are namespaced +// (`type/bug`), and a name passed straight through would write outside the +// scratchpad — which is the one thing this directory exists to prevent. +func (b *Body) name() string { + if b.Name == "" { + return "request" + } + safe := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + return r + } + return '-' + }, b.Name) + if safe = strings.Trim(safe, "-"); safe == "" { + return "request" + } + return safe +} + +// -------------------------------------------------------------------------- +// pagination +// -------------------------------------------------------------------------- + +const ( + // pageLimit is how many rows a list request asks for at a time. Gitea's own + // default is smaller and its maximum is larger; 50 is what the Python this + // replaces used and what the page-budget arithmetic is written against. + pageLimit = 50 + // maxPages bounds any single listing. A tracker with a runaway number of + // rows must not turn one command into an unbounded read. + maxPages = 40 + // PageSlack is how far past the ideal page count a Keep-bounded listing may + // scan before it gives up. The ideal is what Limit would need if every + // payload counted; the slack pays for the ones that do not. Deliberately + // small: "fetch until N are kept" without a bound is "fetch the whole + // tracker" on any repository whose filter matches mostly closed issues. + PageSlack = 4 +) + +// pages GETs a list endpoint page by page and hands each page to each as it +// arrives, stopping when each returns false, when a short page says the list is +// exhausted, or when budget pages have been read. +// +// A callback rather than a slice, because a caller whose budget is spent on +// what it KEEPS cannot be served by a function that fetches everything first: +// the page after the one that completed the budget must never be requested. +func pages[T any](c *Client, path string, limit, budget int, each func([]T) (bool, error)) error { + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + for page := 1; page <= budget; page++ { + var batch []T + if err := c.Call(http.MethodGet, fmt.Sprintf("%s%spage=%d&limit=%d", path, sep, page, limit), nil, &batch); err != nil { + return err + } + if len(batch) == 0 { + return nil + } + more, err := each(batch) + if err != nil || !more { + return err + } + if len(batch) < limit { + return nil // a short page is the last one + } + } + return nil +} + +// paginate follows a list endpoint to exhaustion and returns the whole list. +func paginate[T any](c *Client, path string, limit int) ([]T, error) { + var out []T + err := pages(c, path, limit, maxPages, func(batch []T) (bool, error) { + out = append(out, batch...) + return true, nil + }) + return out, err +} + +// repoPath builds an endpoint under this client's repository. Owner and name +// are escaped: they arrive from a config file, and a file is a thing people +// type into. +func (c *Client) repoPath(suffix string) string { + return "repos/" + url.PathEscape(c.repo.Owner) + "/" + url.PathEscape(c.repo.Name) + "/" + suffix +} + +// repoPathf is repoPath with the issue or label number formatted in. +func (c *Client) repoPathf(format string, args ...any) string { + return c.repoPath(fmt.Sprintf(format, args...)) +} diff --git a/cli/internal/gitea/client_test.go b/cli/internal/gitea/client_test.go new file mode 100644 index 0000000..85900b7 --- /dev/null +++ b/cli/internal/gitea/client_test.go @@ -0,0 +1,396 @@ +package gitea_test + +// The transport is tested against httptest, never against a tracker: a test +// that needs a server somewhere is a test nobody runs. +// +// Every fixture builds a throwaway project in a temp directory and points the +// project walk at it with CLAUDE_PROJECT_DIR. Without that the walk falls +// through to the working directory — which during a test run is this repository +// — and a request dump would land in the developer's own project. Nothing here +// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same, +// so a run can neither read nor overwrite the developer's own tokens. + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "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/wire" +) + +// newProject makes an initialized project and points the walk at it. Returns +// the project root. +func newProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".kettle"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_PROJECT_DIR", dir) + t.Setenv(config.EnvHome, filepath.Join(dir, "config")) + return dir +} + +func newClient(t *testing.T, url string) *gitea.Client { + t.Helper() + c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"}) + if err != nil { + t.Fatalf("New: %v", err) + } + return c +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Errorf("encoding the fake response: %v", err) + } +} + +// A list endpoint is followed to the last page and no further: the short page +// ends it, and the page after that is never asked for. +func TestPaginationFollowsToTheLastPage(t *testing.T) { + newProject(t) + + var asked []string + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + asked = append(asked, r.URL.RequestURI()) + auth = r.Header.Get("Authorization") + + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + n := limit + if page == 3 { + n = 7 // the short page + } else if page > 3 { + n = 0 + } + out := []map[string]any{} + for i := 0; i < n; i++ { + out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"}) + } + writeJSON(t, w, out) + })) + defer srv.Close() + + got, err := newClient(t, srv.URL).ListComments(42) + if err != nil { + t.Fatalf("ListComments: %v", err) + } + if len(got) != 107 { + t.Errorf("got %d comments, want 107 (50 + 50 + 7)", len(got)) + } + if len(asked) != 3 { + t.Errorf("made %d requests (%v), want 3 — a short page is the last one", len(asked), asked) + } + if got[0].ID != 1 || got[106].ID != 107 { + t.Errorf("pages arrived out of order: first %d, last %d", got[0].ID, got[106].ID) + } + // Gitea's own scheme, and what the CLI this replaces sent. + if auth != "token s3cret" { + t.Errorf("Authorization was %q, want %q", auth, "token s3cret") + } + want := "/api/v1/repos/acme/widgets/issues/42/comments?page=1&limit=50" + if asked[0] != want { + t.Errorf("first request was %s, want %s", asked[0], want) + } +} + +// A non-2xx carries the status AND the body, because the status alone has never +// told anybody which of the four things that answer 422 actually happened. +func TestErrorNamesTheStatusAndTheBody(t *testing.T) { + newProject(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`) + })) + defer srv.Close() + + _, err := newClient(t, srv.URL).GetIssue(7) + if err == nil { + t.Fatal("a 422 returned no error") + } + var apiErr *gitea.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error is %T, want *gitea.APIError: %v", err, err) + } + if apiErr.Status != http.StatusUnprocessableEntity { + t.Errorf("Status is %d, want 422", apiErr.Status) + } + if !gitea.StatusIs(err, http.StatusUnprocessableEntity) { + t.Error("StatusIs did not recognize its own error") + } + for _, want := range []string{"422", "label already exists", "GET", "/api/v1/repos/acme/widgets/issues/7"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the error does not mention %q:\n%s", want, err) + } + } + // The token is in a header, so quoting the URL is safe — and it had better + // stay that way. + if strings.Contains(err.Error(), "s3cret") { + t.Errorf("the error quotes the token:\n%s", err) + } +} + +// A request body is filed in the scratchpad, which is a SIBLING of the store +// and never inside it. A call that touches no issue must not materialize an +// issue directory. +func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) { + root := newProject(t) + + var sent []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent, _ = io.ReadAll(r.Body) + writeJSON(t, w, map[string]any{ + "number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"}) + })) + defer srv.Close() + + body := " a & b" + got, err := newClient(t, srv.URL).CreateIssue( + wire.IssueRequest{Title: wire.Set("wire sqlc"), Body: wire.Set(body)}, "issue-wire-sqlc") + if err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if got.Number != 42 { + t.Errorf("got issue #%d, want #42", got.Number) + } + + path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("the request body was not filed at %s: %v", path, err) + } + if string(raw) != string(sent) { + t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent) + } + // A dump escaped to \u003c is unreadable exactly when it is being read. + if !strings.Contains(string(raw), " a & b") { + t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw) + } + // The whole reason the scratchpad is a sibling. + if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) { + t.Errorf("writing a request body materialized the issue store (%v)", err) + } + // A namespaced name must not climb out of the scratchpad. + if _, err := newClient(t, srv.URL).CreateLabel(wire.LabelRequest{Name: "type/bug", Color: "#ee0701"}); err != nil { + t.Fatalf("CreateLabel: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil { + t.Errorf("a label request body was not filed under a safe name: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "type")); !os.IsNotExist(err) { + t.Error("a label name with a slash in it made a directory inside the scratchpad") + } +} + +// A run that sends no body leaves no directory behind — the scratchpad is +// created by the first write and only then. +func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) { + root := newProject(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"number": 42}) + })) + defer srv.Close() + + if _, err := newClient(t, srv.URL).GetIssue(42); err != nil { + t.Fatalf("GetIssue: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) { + t.Errorf("a read created the payload directory (%v)", err) + } +} + +// The milestone filter is re-checked on the client, because Gitea silently +// ignores one it cannot resolve and answers with the whole backlog. Pull +// requests go the same way. +func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) { + newProject(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/milestones") { + writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}}) + return + } + if r.URL.Query().Get("page") != "1" { + writeJSON(t, w, []map[string]any{}) + return + } + writeJSON(t, w, []map[string]any{ + {"number": 1, "title": "in the milestone", "milestone": map[string]any{"id": 3, "title": "v1"}}, + {"number": 2, "title": "another milestone", "milestone": map[string]any{"id": 9, "title": "later"}}, + {"number": 3, "title": "no milestone at all"}, + {"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"}, + "pull_request": map[string]any{"merged": false}}, + }) + })) + defer srv.Close() + + got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50}) + if err != nil { + t.Fatalf("ListIssues: %v", err) + } + if got.Milestone != "v1" { + t.Errorf("resolved milestone is %q, want v1", got.Milestone) + } + if len(got.Issues) != 1 || got.Issues[0].Number != 1 { + t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues) + } + + if _, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "typo", Limit: 50}); err == nil { + t.Error("an unknown milestone was accepted — that reads as a milestone with the whole backlog in it") + } else if !strings.Contains(err.Error(), "have: v1 (id 3)") { + t.Errorf("the error does not say what the repo actually has: %v", err) + } +} + +// A Keep predicate that rejects everything must not turn a bounded read into a +// walk of the whole tracker, and coming up short is reported rather than +// answered in silence. +func TestListIssuesStopsAtThePageBudget(t *testing.T) { + newProject(t) + + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + out := []map[string]any{} + for i := 0; i < limit; i++ { + out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"}) + } + writeJSON(t, w, out) + })) + defer srv.Close() + + got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{ + Limit: 2, + Keep: func(i *wire.Issue) bool { return i.State == "open" }, + }) + if err != nil { + t.Fatalf("ListIssues: %v", err) + } + if pages != gitea.PageSlack { + t.Errorf("read %d page(s), want %d — one ideal page times the slack", pages, gitea.PageSlack) + } + if got.Warning == "" { + t.Error("stopped short of the limit and said nothing about it") + } + // Everything enumerated comes back even though none of it counted: a caller + // with something to say about the ones that did not still can. + if len(got.Issues) != gitea.PageSlack*2 { + t.Errorf("got %d issue(s), want every payload that was enumerated", len(got.Issues)) + } +} + +// A Keep-bounded read stops the moment the budget is full: the page after the +// one that completed it is never requested. +func TestListIssuesStopsAtTheLimit(t *testing.T) { + newProject(t) + + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + out := []map[string]any{} + for i := 0; i < limit; i++ { + out = append(out, map[string]any{"number": pages*100 + i, "state": "open"}) + } + writeJSON(t, w, out) + })) + defer srv.Close() + + got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{ + Limit: 2, + Keep: func(i *wire.Issue) bool { return i.State == "open" }, + }) + if err != nil { + t.Fatalf("ListIssues: %v", err) + } + if pages != 1 { + t.Errorf("read %d page(s), want 1 — the budget was full after the first", pages) + } + if len(got.Issues) != 2 || got.Warning != "" { + t.Errorf("got %d issue(s), warning %q; want 2 and no warning", len(got.Issues), got.Warning) + } +} + +// A dependency endpoint the instance does not have is "no dependencies", not a +// failed pull. A dead connection still is one. +func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) { + newProject(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not implemented", http.StatusNotImplemented) + })) + defer srv.Close() + + got, err := newClient(t, srv.URL).DependencyKeys(42) + if err != nil || len(got) != 0 { + t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err) + } + + srv.Close() + if _, err := newClient(t, srv.URL).Dependencies(42); err == nil { + t.Error("a dead connection was reported as an instance without dependency support") + } +} + +// A half-filled configuration is refused here rather than at the first 401, +// because a 401 names nothing an operator can act on. +func TestNewRefusesAHalfFilledConfiguration(t *testing.T) { + for _, tc := range []struct { + what string + cfg config.Resolved + want string + }{ + {"no url", config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL}, + {"no token", config.Resolved{URL: "u", Owner: "a", Repo: "b"}, "kettle auth add"}, + {"no repo", config.Resolved{URL: "u", Token: "t"}, "kettle init --repo"}, + } { + _, err := gitea.New(&tc.cfg) + if err == nil { + t.Errorf("%s: accepted", tc.what) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: the error does not name the fix (%q): %v", tc.what, tc.want, err) + } + } +} + +// The layering rule, from this side. The transport knows numbers, logins, HTTP +// and JSON; the domain knows none of those, and neither may reach the other. +// +// The bridge is out too, and for a reason of its own: it is the layer that +// translates between the two, so it sits ABOVE both. A transport that imported +// it would be a transport that knows what an issue is, one indirection later — +// and the protocol both of them share, internal/wire, exists precisely so that +// neither has to reach for the other to name a payload. +func TestTransportDoesNotImportTheDomain(t *testing.T) { + out, err := exec.Command("go", "list", "-deps", ".").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, dep := range strings.Fields(string(out)) { + switch { + case strings.HasSuffix(dep, "/internal/issue"): + t.Errorf("the transport imports %s — what an issue IS is not a transport concept", dep) + case strings.HasSuffix(dep, "/internal/mapping"): + t.Errorf("the transport imports %s — translating is a layer of its own, and it sits above this one", dep) + } + } +} diff --git a/cli/internal/gitea/issues.go b/cli/internal/gitea/issues.go new file mode 100644 index 0000000..1629eb9 --- /dev/null +++ b/cli/internal/gitea/issues.go @@ -0,0 +1,332 @@ +package gitea + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +// GetIssue fetches one issue by number. +// +// A number is an address, not a query: this answers for a closed issue exactly +// as it does for an open one. +func (c *Client) GetIssue(number int) (*wire.Issue, error) { + var got wire.Issue + if err := c.Call(http.MethodGet, c.repoPathf("issues/%d", number), nil, &got); err != nil { + return nil, err + } + // A 200 that carries no number is not this issue. Gitea has answered that + // way for a repository whose issue tracker is disabled. + if got.Number == 0 { + return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo) + } + return &got, nil +} + +// CreateIssue files a new issue. name labels the request body in the +// scratchpad; the issue's slug is what makes that dump worth keeping. +func (c *Client) CreateIssue(req wire.IssueRequest, name string) (*wire.Issue, error) { + var got wire.Issue + body := &Body{Name: name, Data: req} + if err := c.Call(http.MethodPost, c.repoPath("issues"), body, &got); err != nil { + return nil, err + } + return &got, nil +} + +// EditIssue patches an existing issue. Only the fields set on req are sent. +func (c *Client) EditIssue(number int, req wire.IssueRequest, name string) (*wire.Issue, error) { + var got wire.Issue + body := &Body{Name: name, Data: req} + if err := c.Call(http.MethodPatch, c.repoPathf("issues/%d", number), body, &got); err != nil { + return nil, err + } + return &got, nil +} + +// SetLabels replaces an issue's labels with exactly these ids. +// +// It exists because Gitea occasionally drops labels handed to it on create, and +// the answer to that is to re-apply them rather than to trust the echo. +func (c *Client) SetLabels(number int, ids []int64, name string) ([]wire.Label, error) { + if ids == nil { + ids = []int64{} + } + var got []wire.Label + body := &Body{Name: name, Data: struct { + Labels []int64 `json:"labels"` + }{ids}} + if err := c.Call(http.MethodPut, c.repoPathf("issues/%d/labels", number), body, &got); err != nil { + return nil, err + } + return got, nil +} + +// ListComments is an issue's whole thread, every page of it. +func (c *Client) ListComments(number int) ([]wire.Comment, error) { + return paginate[wire.Comment](c, c.repoPathf("issues/%d/comments", number), pageLimit) +} + +// CreateComment posts a comment on an issue. +func (c *Client) CreateComment(number int, text, name string) (*wire.Comment, error) { + var got wire.Comment + body := &Body{Name: name, Data: commentBody{Body: text}} + if err := c.Call(http.MethodPost, c.repoPathf("issues/%d/comments", number), body, &got); err != nil { + return nil, err + } + return &got, nil +} + +// EditComment rewrites one comment, addressed by its own id and not by the +// issue it is on — which is how Gitea addresses it. +func (c *Client) EditComment(id int64, text, name string) (*wire.Comment, error) { + var got wire.Comment + body := &Body{Name: name, Data: commentBody{Body: text}} + if err := c.Call(http.MethodPatch, c.repoPathf("issues/comments/%d", id), body, &got); err != nil { + return nil, err + } + return &got, nil +} + +type commentBody struct { + Body string `json:"body"` +} + +// -------------------------------------------------------------------------- +// listing, and the filter the server does not honour +// -------------------------------------------------------------------------- + +// IssueFilter is what a listing asks for. +type IssueFilter struct { + // State is open (the default), closed, or all. + State string + // Labels are label names; an issue must carry all of them. + Labels []string + // Query is Gitea's keyword search over title and body. + Query string + // Milestone is an id or a title. It is resolved against the repository + // before it is trusted — see ResolveMilestone. + Milestone string + // Limit counts the payloads the CALLER cares about, not the ones the server + // returned. Must be 1 or more. + Limit int + // Keep says whether a payload counts against Limit. Without it every + // payload counts and a listing behaves as any other. With it, pages keep + // coming until Limit have counted, and the returned list carries the ones + // that did not count too — they were enumerated, and a caller with + // something to say about them ("11 closed, not stored") still can. + // + // What Keep means is the caller's business; this package only counts. + Keep func(*wire.Issue) bool +} + +// IssueListing is what a filtered read found. +type IssueListing struct { + // Issues are every payload that passed the filter, kept or not. + Issues []wire.Issue + // Milestone is the resolved milestone title, for a receipt. + Milestone string + // Warning is set when a Keep-bounded read ran out of page budget with the + // budget unfilled. Returned rather than printed: the transport does not own + // the operator's terminal, and a caller that is rendering JSON needs it as + // data. + Warning string +} + +// ListIssues reads filtered issue payloads. +// +// One request per page, and a payload already carries the issue body — a whole +// milestone costs one call per page, not one per issue. +// +// Two boundaries hold whatever Keep decides: +// +// - Stop at the limit. The page after the one that completed the budget is +// never requested. +// - Stop at the page budget. A predicate that rejects everything must not turn +// a bounded read into a walk of the whole tracker, so a filtered read scans +// at most PageSlack times the pages Limit would need if every payload +// counted. Hitting that with the budget unfilled sets Warning rather than +// answering short in silence: the caller asked for N and is told it got +// fewer. +func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) { + if f.Limit < 1 { + return nil, fmt.Errorf("a listing limit must be 1 or more, got %d", f.Limit) + } + + out := &IssueListing{} + var milestoneID int64 + if f.Milestone != "" { + ms, err := c.ResolveMilestone(f.Milestone) + if err != nil { + return nil, err + } + milestoneID, out.Milestone = ms.ID, ms.Title + } + + params := url.Values{} + state := f.State + if state == "" { + state = "open" + } + params.Set("state", state) + params.Set("type", "issues") + if len(f.Labels) > 0 { + params.Set("labels", strings.Join(f.Labels, ",")) + } + if f.Query != "" { + params.Set("q", f.Query) + } + if out.Milestone != "" { + params.Set("milestones", out.Milestone) + } + path := c.repoPath("issues?" + params.Encode()) + + perPage := min(f.Limit, pageLimit) + ideal := max(1, (f.Limit+perPage-1)/perPage) + budget := ideal + if f.Keep != nil { + budget = ideal * PageSlack + } + + kept, seen, lastFull := 0, 0, false + err := pages(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) { + seen++ + lastFull = len(batch) == perPage + for i := range batch { + p := &batch[i] + if !matches(p, milestoneID, f.Labels) { + continue + } + out.Issues = append(out.Issues, *p) + if f.Keep == nil || f.Keep(p) { + kept++ + if kept >= f.Limit { + return false, nil + } + } + } + return true, nil + }) + if err != nil { + return nil, err + } + if f.Keep != nil && seen >= budget && lastFull { + out.Warning = fmt.Sprintf("scanned %d page(s) and stopped %d short of the limit of %d"+ + " — there may be more; narrow the filter or raise the limit", budget, f.Limit-kept, f.Limit) + } + return out, nil +} + +// matches re-checks on the client what the server was already asked for. +// +// Not paranoia: Gitea silently IGNORES a `milestones=` value it cannot resolve +// and answers with the whole backlog, which is why the milestone is resolved to +// an id first and every payload is checked against that id here. The same +// re-check on labels costs nothing, and `pull_request` is the one filter that +// matters most — a pull request rendered as a unit of work is not a bug the +// operator can see until it is in the store. +// +// A function and not a method: the payload is the protocol's, and re-checking a +// filter the server ignored is this package's business, not the protocol's. +func matches(i *wire.Issue, milestoneID int64, labels []string) bool { + if i.IsPullRequest() { + return false + } + if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) { + return false + } + have := make(map[string]bool, len(i.Labels)) + for _, l := range i.Labels { + have[l.Name] = true + } + for _, want := range labels { + if !have[want] { + return false + } + } + return true +} + +// -------------------------------------------------------------------------- +// dependencies +// -------------------------------------------------------------------------- + +// issueMeta is Gitea's IssueMeta: how a dependency names another issue. +type issueMeta struct { + Index int `json:"index"` + Owner string `json:"owner"` + Repo string `json:"repo"` +} + +// Dependencies are the issues that block this one — Gitea's own dependency +// links, read in the direction AddDependency writes them. +// +// An instance that does not have the endpoint, or has dependencies turned off +// for this repository, answers with a status rather than a list. That is +// reported as "no dependencies" and not as a failure: a pull must still bring +// the issue itself back from a tracker whose dependency support is off. +// +// Deliberately narrower than the Python it replaces, which swallowed every +// failure here including a dead connection. "The server said no" and "there was +// no server" are different answers, and only the first one means the feature is +// missing. +func (c *Client) Dependencies(number int) ([]wire.Issue, error) { + var got []wire.Issue + err := c.Call(http.MethodGet, c.repoPathf("issues/%d/dependencies", number), nil, &got) + var apiErr *APIError + if errors.As(err, &apiErr) { + return nil, nil + } + if err != nil { + return nil, err + } + return got, nil +} + +// DependencyKeys is the same links as cross-repo handles — what a repeat push +// compares against so it does not POST a link the tracker already has. +// +// A bare number is ambiguous the moment a dependency lives in another +// repository, and Gitea lets it, so the repository travels with it. +func (c *Client) DependencyKeys(number int) ([]wire.Key, error) { + deps, err := c.Dependencies(number) + if err != nil { + return nil, err + } + out := make([]wire.Key, 0, len(deps)) + for i := range deps { + out = append(out, deps[i].KeyIn(c.repo)) + } + return out, nil +} + +// AddDependency makes issue number depend on dep. +// +// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1): +// +// POST /repos/{owner}/{repo}/issues/{index}/dependencies +// body: IssueMeta — {"index": , "owner": "", "repo": ""} +// "Make the issue in the url depend on the issue in the form." +// +// So the URL names the blocked issue and the body the blocker, which is the +// direction Dependencies reads back. A link that already exists answers 409, so +// callers pre-filter with DependencyKeys and treat a failure here as a note +// rather than an abort: one missing cross-link must not undo a push that has +// already created issues. +func (c *Client) AddDependency(number int, dep wire.Key) error { + if dep.Repo.Zero() { + return fmt.Errorf("dependency %s names no repository — a link needs owner/repo#number", dep) + } + if dep.Number < 1 { + return fmt.Errorf("dependency %s names no issue number", dep) + } + body := &Body{ + Name: fmt.Sprintf("dep-%d-%d", number, dep.Number), + Data: issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name}, + } + return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil) +} diff --git a/cli/internal/gitea/labels.go b/cli/internal/gitea/labels.go new file mode 100644 index 0000000..33effa6 --- /dev/null +++ b/cli/internal/gitea/labels.go @@ -0,0 +1,108 @@ +package gitea + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +// ListLabels is every label in the repository, every page of it. +// +// A bootstrap decides its plan against this and never against a cache: a cache +// answers "what did we create last time", and the question is "what does the +// repository have right now". +func (c *Client) ListLabels() ([]wire.Label, error) { + return paginate[wire.Label](c, c.repoPath("labels"), 100) +} + +// CreateLabel adds a label to the repository. +// +// Through the API rather than through any CLI wrapper, because `exclusive` — +// the flag that makes `type/*` behave like a single choice — is not something +// the `tea` client could set. +// +// What a label MEANS is not decided here either: this creates what it is +// handed. +func (c *Client) CreateLabel(req wire.LabelRequest) (*wire.Label, error) { + var got wire.Label + body := &Body{Name: "label-" + req.Name, Data: req} + if err := c.Call(http.MethodPost, c.repoPath("labels"), body, &got); err != nil { + return nil, err + } + if got.ID == 0 { + return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", req.Name) + } + return &got, nil +} + +// EditLabel patches an existing label by id. +func (c *Client) EditLabel(id int64, req wire.LabelRequest) (*wire.Label, error) { + var got wire.Label + body := &Body{Name: "label-" + req.Name, Data: req} + if err := c.Call(http.MethodPatch, c.repoPathf("labels/%d", id), body, &got); err != nil { + return nil, err + } + return &got, nil +} + +// ListMilestones is every milestone in the repository, open and closed. +// +// Both states, always: a milestone is closed the moment its work is done, and a +// listing that hid those would fail to resolve exactly the filter somebody +// types when they want to see what was in it. +func (c *Client) ListMilestones() ([]wire.Milestone, error) { + return paginate[wire.Milestone](c, c.repoPath("milestones?state=all"), 100) +} + +// ResolveMilestone finds a milestone by id or by title, and fails when there is +// none. +// +// It fails LOUDLY, and that is the whole point of resolving before filtering: +// Gitea silently ignores a `milestones=` filter it cannot resolve and answers +// with the entire backlog. A typo in a milestone name would otherwise read as +// "your milestone has 300 issues in it". +func (c *Client) ResolveMilestone(value string) (*wire.Milestone, error) { + got, err := c.ListMilestones() + if err != nil { + return nil, err + } + for i := range got { + if got[i].Title == value || strconv.FormatInt(got[i].ID, 10) == value { + return &got[i], nil + } + } + have := make([]string, 0, len(got)) + for _, m := range got { + have = append(have, fmt.Sprintf("%s (id %d)", m.Title, m.ID)) + } + if len(have) == 0 { + have = []string{"none"} + } + return nil, fmt.Errorf("no milestone %q in %s — have: %s", value, c.repo, strings.Join(have, ", ")) +} + +// FindMilestone is the milestone with this title, or nil when the repository +// has no such milestone. +// +// The quiet counterpart of ResolveMilestone, for a push: an issue naming a +// milestone the tracker does not have is filed without one, because refusing +// the whole push over a field the tracker will happily accept as empty helps +// nobody. "none" and "" are both "no milestone". +func (c *Client) FindMilestone(title string) (*wire.Milestone, error) { + if title == "" || title == "none" { + return nil, nil + } + got, err := c.ListMilestones() + if err != nil { + return nil, err + } + for i := range got { + if got[i].Title == title { + return &got[i], nil + } + } + return nil, nil +} diff --git a/cli/internal/gitea/remotemap.go b/cli/internal/gitea/remotemap.go new file mode 100644 index 0000000..88fd57a --- /dev/null +++ b/cli/internal/gitea/remotemap.go @@ -0,0 +1,88 @@ +package gitea + +import ( + "encoding/json" + "os" + "path/filepath" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +// RemoteMapName is the ledger's file name, beside the issues it indexes. +const RemoteMapName = ".remote.json" + +// RemoteMap is the number -> slug ledger: {"owner/repo#42": "wire-sqlc-appclick"}. +// +// ITS ENTRIES OUTLIVE THE FILES THEY NAME, and that is deliberate rather than a +// leak. A push deletes an issue's file the moment the tracker confirms the +// write, and the entry left behind is what makes the next pull of that number +// land on the same slug — so every `depends:` that pointed at it still +// resolves. Nothing prunes them, because "no file" no longer means "no such +// issue"; eviction does not prune it either, for the same reason a push does +// not. A stale entry costs one line of JSON and is corrected the next time that +// number is pulled. +// +// It is a cache, not a record. The slug also travels tracker-side, in the issue +// body, so losing this file costs a re-pull and not information — which is why +// Load never fails and why a rebuild is a MERGE and never a replacement. The +// order of authority: +// +// the tracker the issue, and the marker naming its slug +// .remote.json a local number -> slug ledger, a cache of that marker +// the store whatever happens to be checked out right now +// +// The store is a subset of what the ledger knows, so a rebuild that started +// from the files alone would throw away every entry it cannot see. Start from +// Load, add what the files say, Save. +type RemoteMap map[string]string + +// RemoteMapPath is where the ledger lives: inside the issue store, beside the +// issues. root is the STORE, not the payload scratchpad — this file is +// bookkeeping about issues and belongs where they are. +func RemoteMapPath(root string) string { return filepath.Join(root, RemoteMapName) } + +// LoadRemoteMap reads the ledger. +// +// A missing, unreadable or malformed file is an empty ledger and never an +// error. The ledger is a cache of markers the tracker holds, so refusing to run +// because it cannot be parsed would block the very pull that would rebuild it — +// and the cost of starting empty is one re-pull, never a lost issue. +func LoadRemoteMap(root string) RemoteMap { + raw, err := os.ReadFile(RemoteMapPath(root)) + if err != nil { + return RemoteMap{} + } + var got RemoteMap + if err := json.Unmarshal(raw, &got); err != nil || got == nil { + return RemoteMap{} + } + return got +} + +// Save writes the ledger, creating the directory if it is not there. +// +// The one write in this package allowed to create the store, and only because +// of when it happens: the ledger is written the instant the tracker confirms a +// push and BEFORE the local file is deleted, so failing it over a missing +// directory would lose the slug at exactly the moment the local copy stops +// being the record. +// +// Indented and key-sorted — encoding/json sorts map keys for us — because this +// file is read by people and diffed by git as often as it is read by the +// binary. +func (m RemoteMap) Save(root string) error { + if err := os.MkdirAll(root, 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(RemoteMapPath(root), append(raw, '\n'), 0o644) +} + +// Slug is the local name recorded for a key, or "". +func (m RemoteMap) Slug(k wire.Key) string { return m[k.String()] } + +// Set records that a key is known locally under this slug. +func (m RemoteMap) Set(k wire.Key, slug string) { m[k.String()] = slug } diff --git a/cli/internal/gitea/remotemap_test.go b/cli/internal/gitea/remotemap_test.go new file mode 100644 index 0000000..cc88e69 --- /dev/null +++ b/cli/internal/gitea/remotemap_test.go @@ -0,0 +1,80 @@ +package gitea_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/gitea" + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +func TestRemoteMapRoundTrips(t *testing.T) { + root := filepath.Join(t.TempDir(), "issues") + key := wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 42} + + m := gitea.RemoteMap{} + m.Set(key, "wire-sqlc-appclick") + if err := m.Save(root); err != nil { + t.Fatalf("Save: %v", err) + } + + path := gitea.RemoteMapPath(root) + if want := filepath.Join(root, ".remote.json"); path != want { + t.Errorf("the ledger is at %s, want %s — beside the issues it indexes", path, want) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading the ledger: %v", err) + } + if !strings.Contains(string(raw), `"acme/widgets#42": "wire-sqlc-appclick"`) { + t.Errorf("the ledger is not readable by a human:\n%s", raw) + } + + back := gitea.LoadRemoteMap(root) + if got := back.Slug(key); got != "wire-sqlc-appclick" { + t.Errorf("the key came back as %q, want wire-sqlc-appclick", got) + } + if got := back.Slug(wire.Key{Repo: key.Repo, Number: 7}); got != "" { + t.Errorf("an unrecorded key answered %q", got) + } + + // A rebuild is a merge and never a replacement: what is already recorded + // survives an entry added on top of it. This is what makes a pull of a + // number whose file was deleted by a push land on the same slug. + second := wire.Key{Repo: key.Repo, Number: 43} + back.Set(second, "drop-the-wiki") + if err := back.Save(root); err != nil { + t.Fatalf("Save: %v", err) + } + again := gitea.LoadRemoteMap(root) + if again.Slug(key) != "wire-sqlc-appclick" || again.Slug(second) != "drop-the-wiki" { + t.Errorf("a second save lost an entry: %v", again) + } +} + +// The ledger is a cache of markers the tracker holds, so an unreadable one must +// not stop the pull that would rebuild it. +func TestRemoteMapSurvivesAMissingOrMangledFile(t *testing.T) { + root := t.TempDir() + + if got := gitea.LoadRemoteMap(filepath.Join(root, "nowhere")); len(got) != 0 { + t.Errorf("a missing ledger loaded as %v", got) + } + if err := os.WriteFile(gitea.RemoteMapPath(root), []byte("{ not json at all"), 0o644); err != nil { + t.Fatal(err) + } + got := gitea.LoadRemoteMap(root) + if len(got) != 0 { + t.Errorf("a mangled ledger loaded as %v", got) + } + // Still writable afterwards: an unreadable ledger costs a re-pull, not a run. + got.Set(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}, "first") + if err := got.Save(root); err != nil { + t.Fatalf("Save over a mangled ledger: %v", err) + } + if gitea.LoadRemoteMap(root).Slug(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}) != "first" { + t.Error("the ledger did not come back after being rewritten") + } +} diff --git a/cli/internal/issue/checkbox.go b/cli/internal/issue/checkbox.go new file mode 100644 index 0000000..49cee3e --- /dev/null +++ b/cli/internal/issue/checkbox.go @@ -0,0 +1,185 @@ +package issue + +import ( + "fmt" + "regexp" + "strings" +) + +// A checkbox is the one part of a body that is *state* and not prose, so the +// format gives it markup of its own. It is item markup, not a property of one +// section: `## Acceptance criteria` is the usual home, but a type/feature +// keeps its children as checkboxes under `## Issues`. The scan is therefore +// over the whole text and the heading is only recorded, never required. +var ( + // The trailing group stands in for a lookahead RE2 does not have: after + // the bracket there is either whitespace and then anything, or end of line. + checkboxRe = regexp.MustCompile( + `^(?P[ \t]*)(?P[-*+]|\d+[.)])[ \t]+` + + `\[(?P[ xX])\](?P[ \t].*|)$`) + // Any list item — a sibling ends the item above it, checkbox or not. + listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)])([ \t]|$)`) + fenceRe = regexp.MustCompile("^[ \t]{0,3}(`{3,}|~{3,})") +) + +// Checkbox is one checkbox item found in a text. +type Checkbox struct { + // Index is the 1-based position in the list — what a user types to pick it. + Index int + // Line is the 1-based line of the `- [ ]` marker, in the text given. + Line int + // EndLine is the 1-based last line of the item, continuations included. + EndLine int + // Checked is true for [x] / [X]. + Checked bool + // Text is the item's text; continuation lines joined with one space. + Text string + // Section is the nearest preceding `## ` heading, "" above the first one. + Section string +} + +// Checkboxes returns every checkbox item in text, in document order. +// +// A pure function of the string it is given — no I/O, no store, no tracker. +// Pass an issue body to get body-relative line numbers, or a whole file to get +// file-relative ones; nothing else changes. +// +// Rules: +// +// - Only a line matching checkboxRe opens an item. A wrapped ("continuation") +// line is part of the item above it, never an item of its own; the item runs +// to the next blank line, heading, code fence, or list marker. +// - Fenced code blocks are skipped whole: `- [ ]` inside a fence is an example +// of the markup, not a box anybody may tick. +// - `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested +// lists are seen too. +func Checkboxes(text string) []Checkbox { + lines := splitLines(text) + var items []Checkbox + section, fence := "", "" + + for n, line := range lines { + if m := fenceRe.FindStringSubmatch(line); m != nil { + tok := m[1] + switch { + case fence == "": + fence = tok + case tok[0] == fence[0] && len(tok) >= len(fence): + fence = "" + } + continue + } + if fence != "" { + continue + } + if strings.HasPrefix(line, "## ") { + section = strings.TrimSpace(line) + continue + } + if strings.HasPrefix(line, "# ") { + section = "" + continue + } + m := checkboxRe.FindStringSubmatch(line) + if m == nil { + continue + } + end := n + 1 + parts := []string{strings.TrimSpace(m[4])} + for k := n + 1; k < len(lines); k++ { + next := lines[k] + if strings.TrimSpace(next) == "" || strings.HasPrefix(next, "#") || + fenceRe.MatchString(next) || listItemRe.MatchString(next) { + break + } + end = k + 1 + parts = append(parts, strings.TrimSpace(next)) + } + var kept []string + for _, p := range parts { + if p != "" { + kept = append(kept, p) + } + } + items = append(items, Checkbox{ + Index: len(items) + 1, + Line: n + 1, + EndLine: end, + Checked: m[3] != " ", + Text: strings.Join(kept, " "), + Section: section, + }) + } + return items +} + +// SetCheckbox returns text with the checkbox on the given 1-based line set to +// checked. +// +// Pure, and deliberately surgical: exactly one byte of the input changes — the +// one between the brackets. Everything else, including trailing whitespace and +// the item's own wording, comes back byte for byte. That is the whole point: +// ticking a box must not produce a diff wider than the state that changed. +// +// Already in the requested state is a no-op — text comes back unchanged, and +// an existing [X] keeps its capital. +func SetCheckbox(text string, line int, checked bool) (string, error) { + off := 0 + for n := 1; off <= len(text); n++ { + nl := strings.IndexByte(text[off:], '\n') + var raw string + if nl == -1 { + raw = text[off:] + } else { + raw = text[off : off+nl] + } + if n == line { + m := checkboxRe.FindStringSubmatchIndex(strings.TrimRight(raw, "\r")) + if m == nil { + return "", fmt.Errorf("line %d is not a checkbox item", line) + } + box := off + m[6] // group 3: box + if (text[box] != ' ') == checked { + return text, nil + } + c := byte(' ') + if checked { + c = 'x' + } + return text[:box] + string(c) + text[box+1:], nil + } + if nl == -1 { + break + } + off += nl + 1 + } + return "", fmt.Errorf("line %d is past the end of the text", line) +} + +// CheckboxProgress is (done, total) over every checkbox in text; (0, 0) when +// it has none. +// +// Computed on the fly, on purpose. Progress is not a metadata field: it is the +// body read back, and the body is the only place the state lives. +func CheckboxProgress(text string) (done, total int) { + items := Checkboxes(text) + for _, c := range items { + if c.Checked { + done++ + } + } + return done, len(items) +} + +// splitLines is strings.Split minus the phantom final element a trailing +// newline produces, matching Python's str.splitlines(). +func splitLines(text string) []string { + if text == "" { + return nil + } + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + if last := len(lines) - 1; lines[last] == "" { + lines = lines[:last] + } + return lines +} diff --git a/cli/internal/issue/checkbox_test.go b/cli/internal/issue/checkbox_test.go new file mode 100644 index 0000000..1c2bc7e --- /dev/null +++ b/cli/internal/issue/checkbox_test.go @@ -0,0 +1,117 @@ +package issue + +import "testing" + +const boxes = `## Acceptance criteria +- [ ] первый пункт +- [x] второй пункт, который + переносится на вторую строку +* [X] третий +1. [ ] четвёртый + +## Notes +` + "```" + ` +- [ ] это пример разметки, а не состояние +` + "```" + ` +` + +func TestCheckboxesReadTheWholeBody(t *testing.T) { + items := Checkboxes(boxes) + if len(items) != 4 { + t.Fatalf("found %d items, want 4: %+v", len(items), items) + } + if items[1].Text != "второй пункт, который переносится на вторую строку" { + t.Errorf("continuation not joined: %q", items[1].Text) + } + if items[1].EndLine != 4 { + t.Errorf("end line = %d, want 4", items[1].EndLine) + } + if !items[2].Checked { + t.Error("[X] must read as checked") + } + if items[3].Section != ACSection { + t.Errorf("section = %q", items[3].Section) + } + for _, c := range items { + if c.Section == "## Notes" { + t.Error("a checkbox inside a code fence was counted") + } + } +} + +func TestCheckboxProgressIsCountedOffTheBody(t *testing.T) { + done, total := CheckboxProgress(boxes) + if done != 2 || total != 4 { + t.Errorf("progress = %d/%d, want 2/4", done, total) + } +} + +func TestSetCheckboxChangesExactlyOneByte(t *testing.T) { + items := Checkboxes(boxes) + got, err := SetCheckbox(boxes, items[0].Line, true) + if err != nil { + t.Fatal(err) + } + if len(got) != len(boxes) { + t.Fatalf("length changed: %d -> %d", len(boxes), len(got)) + } + diff := 0 + for i := range got { + if got[i] != boxes[i] { + diff++ + } + } + if diff != 1 { + t.Errorf("%d bytes changed, want 1", diff) + } +} + +func TestSetCheckboxIsANoOpWhenAlreadyInState(t *testing.T) { + items := Checkboxes(boxes) + // [X] keeps its capital: the state already matches, so nothing is rewritten. + got, err := SetCheckbox(boxes, items[2].Line, true) + if err != nil { + t.Fatal(err) + } + if got != boxes { + t.Error("an already-checked box was rewritten") + } +} + +func TestSetCheckboxRefusesALineThatIsNotOne(t *testing.T) { + if _, err := SetCheckbox(boxes, 1, true); err == nil { + t.Error("ticking a heading must fail") + } + if _, err := SetCheckbox(boxes, 9999, true); err == nil { + t.Error("ticking past the end must fail") + } +} + +func TestBodyDepRefsOnlyReadTheDepSections(t *testing.T) { + body := `## Summary +смотри также some-other-issue, который не зависимость + +## Depends on +- migrate-schema — нужна схема +- add-pool-cfg + +## Issues +- [ ] wire-sqlc-appclick — часть +- [ ] #42 +` + got := BodyDepRefs(body) + want := []DepRef{ + {DependsSection, "migrate-schema"}, + {DependsSection, "add-pool-cfg"}, + {IssuesSection, "wire-sqlc-appclick"}, + {IssuesSection, "#42"}, + } + if len(got) != len(want) { + t.Fatalf("got %+v, want %+v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ref %d = %+v, want %+v", i, got[i], want[i]) + } + } +} diff --git a/cli/internal/issue/depsection.go b/cli/internal/issue/depsection.go new file mode 100644 index 0000000..becbdea --- /dev/null +++ b/cli/internal/issue/depsection.go @@ -0,0 +1,59 @@ +package issue + +import ( + "regexp" + "strings" +) + +// A reference is a slug, or `#N` on an issue that came from a tracker. +var depRefRe = regexp.MustCompile(`#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b`) + +// DepRef is one dependency reference written in the body prose, carried out +// with the section it was found in. +// +// The section travels with the reference so a caller can name the one the +// reader actually has in front of them: a container's children come from +// `## Issues`, and pointing at `## Depends on` would name a section that is not +// in the file. +type DepRef struct { + Section string + Ref string +} + +// BodyDepRefs returns every reference under one of DepSections, deduplicated +// on first sight, in order of first appearance. +// +// Never from prose elsewhere, or a graph walk would drag in half the backlog. +func BodyDepRefs(body string) []DepRef { + var out []DepRef + seen := map[string]bool{} + section := "" + + for _, line := range splitLines(body) { + if strings.HasPrefix(line, "## ") { + head := strings.TrimSpace(line) + section = "" + for _, s := range DepSections { + if head == s { + section = head + break + } + } + continue + } + if section == "" { + continue + } + for _, tok := range depRefRe.FindAllStringSubmatch(line, -1) { + ref := tok[2] + if tok[1] != "" { + ref = "#" + tok[1] + } + if !seen[ref] { + seen[ref] = true + out = append(out, DepRef{Section: section, Ref: ref}) + } + } + } + return out +} diff --git a/cli/internal/issue/evict.go b/cli/internal/issue/evict.go new file mode 100644 index 0000000..c49f695 --- /dev/null +++ b/cli/internal/issue/evict.go @@ -0,0 +1,155 @@ +package issue + +import ( + "os" + "sort" +) + +// Closed issues leave the store. The store is a working set, not an archive. +// +// WHAT IS EVICTED, and it is two conditions, both read off the file: +// +// state: closed the work is done +// origin: 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 therefore never evicted, in any state, not even when named explicitly: +// a closed local issue is reported and kept. The only files that go are ones +// whose own metadata says the work can be fetched back, which is the same trade +// a push makes when it drops a file the tracker has just confirmed. +// +// That parallel is exact except for where the confirmation comes from. Push has +// to ask the tracker, because it is the tracker that just changed. Eviction asks +// the file, because state and origin are domain fields and the answer is already +// in the store — which is why this lives in the domain and needs no network, no +// login, and no tracker. The sync layer's variant refreshes state from the +// tracker first and then calls Evict, so there is exactly one implementation of +// "what may be evicted" and it is this one. +// +// NOT A ONE-OFF MIGRATION. A pull by number fetches an issue in any state — a +// number is an address, not a query — so a closed issue pulled after an eviction +// lands on disk again. That is the tracker being asked a direct question, not a +// regression; evict it again when you are done with it. +// +// `.remote.json` is deliberately NOT pruned. It is the local number -> slug +// ledger, its entries outlive the files they name, and an evicted issue is in +// exactly that state. INDEX.md is rebuilt, because it IS a view of the +// directory. + +const closed = "closed" + +// LocalReason is printed whether or not the issue was named, because "this +// closed thing is still here" needs an answer every time. +const LocalReason = "origin: " + Local + " — this file IS the issue" + +// Evicted is one issue that left the store, with every file that went with it. +type Evicted struct { + ID string + Paths []string +} + +// Kept is one issue that was considered and stayed, with the reason. +type Kept struct { + ID string + Why string + Open bool // true when it is simply not closed yet — the normal case +} + +// EvictReport is what a run did, or would have done. +type EvictReport struct { + Evicted []Evicted + Kept []Kept + DryRun bool + IndexPath string + IndexCount int +} + +// Classify splits the store into what may be evicted, what is protected, and +// what is still open. +// +// Pure — it reads the loaded issues and decides; nothing here touches disk. +// ids restricts the question to those issues; empty considers the whole store. +// A protected issue is returned as such even when it was named explicitly: +// naming a file does not make deleting it safe. +func Classify(issues map[string]*Issue, ids []string) (evict, protected, stillOpen []string) { + 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 + } + switch { + case i.State != closed: + stillOpen = append(stillOpen, id) + case i.IsLocal(): + protected = append(protected, id) + default: + evict = append(evict, id) + } + } + return evict, protected, stillOpen +} + +// Remove deletes everything the store holds under one slug and returns the +// paths that went. +// +// Deliberately dumb: it takes an id, not a decision. Whether an issue may go is +// settled by Classify before this is reached, so the dangerous half of the +// operation has no branches in it at all. +func Remove(root, id string) ([]string, error) { + var gone []string + for _, p := range SlugFiles(root, id) { + if err := os.Remove(p); err != nil { + return gone, err + } + gone = append(gone, p) + } + return gone, nil +} + +// Evict classifies, removes, and rebuilds the index. The one implementation, +// called both by the offline command and by the sync layer — which does nothing +// to this decision except hand over issues whose state it has just refreshed +// from the tracker. +func Evict(root string, issues map[string]*Issue, ids []string, dryRun bool) (*EvictReport, error) { + evict, protected, stillOpen := Classify(issues, ids) + rep := &EvictReport{DryRun: dryRun} + + for _, id := range evict { + var paths []string + if dryRun { + paths = SlugFiles(root, id) + } else { + var err error + if paths, err = Remove(root, id); err != nil { + return rep, err + } + } + rep.Evicted = append(rep.Evicted, Evicted{ID: id, Paths: paths}) + } + for _, id := range protected { + rep.Kept = append(rep.Kept, Kept{ID: id, Why: LocalReason}) + } + for _, id := range stillOpen { + rep.Kept = append(rep.Kept, Kept{ID: id, Why: "state: " + issues[id].State, Open: true}) + } + + // Only when something actually went: the index is a view of the directory, + // and rewriting it after a run that changed nothing is a write nobody asked + // for. + if !dryRun && len(rep.Evicted) > 0 { + path, n, err := BuildIndex(root) + if err != nil { + return rep, err + } + rep.IndexPath, rep.IndexCount = path, n + } + return rep, nil +} diff --git a/cli/internal/issue/graph.go b/cli/internal/issue/graph.go new file mode 100644 index 0000000..64ddb3f --- /dev/null +++ b/cli/internal/issue/graph.go @@ -0,0 +1,106 @@ +package issue + +import "sort" + +// Graph is the edge list read off the `depends:` metadata — the authoritative +// one. Body prose is never walked. +func Graph(issues map[string]*Issue) map[string][]string { + out := make(map[string][]string, len(issues)) + for id, i := range issues { + out[id] = append([]string{}, i.Depends...) + } + return out +} + +// Dependents lists who depends on id — the upward direction. +func Dependents(issues map[string]*Issue, id string) []string { + var out []string + for other, i := range issues { + if contains(i.Depends, id) { + out = append(out, other) + } + } + sort.Strings(out) + return out +} + +// TopoOrder puts dependencies first. +// +// Cycles are broken deterministically rather than raising: a cycle is a data +// problem for the caller to report, not a reason to refuse to order the rest. +func TopoOrder(ids []string, edges map[string][]string) []string { + const ( + open = 1 + done = 2 + ) + state := map[string]int{} + var order []string + + var visit func(string) + visit = func(n string) { + switch state[n] { + case done, open: // open = a back edge; leave it unresolved + return + } + state[n] = open + for _, d := range edges[n] { + if _, ok := edges[d]; ok { + visit(d) + } + } + state[n] = done + order = append(order, n) + } + + for _, n := range ids { + visit(n) + } + return order +} + +// FindCycles returns one id list per cycle. Empty when the graph is a DAG. +func FindCycles(edges map[string][]string) [][]string { + const ( + open = 1 + done = 2 + ) + state := map[string]int{} + var stack []string + var cycles [][]string + + var visit func(string) + visit = func(n string) { + state[n] = open + stack = append(stack, n) + for _, d := range edges[n] { + if _, ok := edges[d]; !ok { + continue + } + if state[d] == open { + for i, s := range stack { + if s == d { + cycles = append(cycles, append(append([]string{}, stack[i:]...), d)) + break + } + } + } else if state[d] == 0 { + visit(d) + } + } + stack = stack[:len(stack)-1] + state[n] = done + } + + // Sorted so the report is the same on every run; Go map order is not. + ids := make([]string, 0, len(edges)) + for n := range edges { + ids = append(ids, n) + } + sort.Strings(ids) + for _, n := range ids { + if state[n] == 0 { + visit(n) + } + } + return cycles +} diff --git a/cli/internal/issue/index.go b/cli/internal/issue/index.go new file mode 100644 index 0000000..e4af997 --- /dev/null +++ b/cli/internal/issue/index.go @@ -0,0 +1,128 @@ +package issue + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var treeFileRe = regexp.MustCompile(`^tree-.+\.md$`) + +const indexPreamble = "Every issue this project knows about. `origin: local` means it " + + "exists nowhere else — a complete state, not a pending one. Any other value " + + "names the tracker it also lives in; the handle is in the file. `progress` " + + "counts the body's checkboxes, ticked over total, and is blank for an issue " + + "that has none — read off the body at build time, stored nowhere. Rebuild " + + "with `kettle index`; tick a box with `kettle ac`." + +// BuildIndex rewrites INDEX.md from what is on disk and returns its path and +// the number of issues in it. +// +// An index of a store that is not there is not an empty index, it is a bad +// path: failing beats writing INDEX.md into a directory nobody asked for. An +// existing store with nothing in it is a legitimate thing to index and gets an +// "_empty_" table. +func BuildIndex(root string) (string, int, error) { + if err := RequireStore(root); err != nil { + return "", 0, err + } + issues, err := LoadAll(root) + if err != nil { + return "", 0, err + } + + ids := make([]string, 0, len(issues)) + for id := range issues { + ids = append(ids, id) + } + sort.Strings(ids) + + out := []string{"# Issue store", "", indexPreamble, ""} + if len(ids) > 0 { + out = append(out, + "| id | state | progress | type | labels | title | milestone | depends | origin |", + "|---|---|---|---|---|---|---|---|---|") + for _, id := range ids { + i := issues[id] + var rest []string + for _, l := range i.Labels { + if !strings.HasPrefix(l, "type/") { + rest = append(rest, l) + } + } + out = append(out, fmt.Sprintf("| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |", + id, id, cell(i.State), progress(i.Body), cell(i.Type()), + cellList(rest), cell(i.Title), cell(i.Milestone), + cellList(i.Depends), cell(i.Origin))) + } + } else { + out = append(out, "_empty_") + } + + if trees := treeFiles(root); len(trees) > 0 { + out = append(out, "", "## Dependency trees", "") + for _, t := range trees { + out = append(out, fmt.Sprintf("- [%s](%s)", t, t)) + } + } + + if cycles := FindCycles(Graph(issues)); len(cycles) > 0 { + out = append(out, "", "## Dependency cycles", "") + for _, c := range cycles { + out = append(out, "- "+strings.Join(c, " -> ")) + } + } + out = append(out, "") + + path := filepath.Join(root, "INDEX.md") + if err := os.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644); err != nil { + return "", 0, err + } + return path, len(ids), nil +} + +// progress is `3/7` for a body with checkboxes, "" for one without. +// +// Counted from the body every time the index is built and stored nowhere — the +// boxes are the state, and a second copy of it in a metadata field would be +// wrong by the next edit. +func progress(body string) string { + done, total := CheckboxProgress(body) + if total == 0 { + return "" + } + return fmt.Sprintf("%d/%d", done, total) +} + +func cell(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "—" + } + return strings.ReplaceAll(v, "|", `\|`) +} + +func cellList(xs []string) string { + if len(xs) == 0 { + return "—" + } + return strings.Join(xs, ", ") +} + +func treeFiles(root string) []string { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if treeFileRe.MatchString(e.Name()) { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} diff --git a/cli/internal/issue/issue.go b/cli/internal/issue/issue.go new file mode 100644 index 0000000..cca91b0 --- /dev/null +++ b/cli/internal/issue/issue.go @@ -0,0 +1,229 @@ +// Package issue is what an issue IS. The domain layer. +// +// It knows the canonical markdown format, the label taxonomy, validation, and +// the dependency graph. It knows NOTHING about any tracker: no Gitea, no +// logins, no HTTP, no issue numbers. The layering rule is mechanically checked +// — see TestDomainImportsNothing, which walks this package's transitive +// dependencies and fails on anything outside the standard library and +// internal/project. +// +// Delete the transport entirely and this layer keeps working: issues that live +// only on this machine are first-class, not drafts on their way somewhere. +// +// Identity is a slug derived from the title, and it is the only identity the +// domain has. The file name is the id: +// +// .tea/issues/wire-sqlc-appclick.md +// +// --- +// id: wire-sqlc-appclick +// state: open +// labels: [type/task, tech/sql] +// assignees: [naudachu] +// milestone: v0.2 +// depends: [migrate-schema] +// origin: gitea +// gitea: owner/repo#42 +// synced: 2026-08-07T18:40:00Z +// --- +// # Wire sqlc into the appclick repo layer +// +// ## Summary +// ... +// +// Keys down to origin are owned here. Everything below is written by the sync +// layer; this package carries those keys through load/save verbatim in Extra +// and never reads them. That passthrough is what lets one file represent both +// a local issue and a synced one without the domain learning a second +// vocabulary. +// +// Every metadata field is one line and lists are inline, so plain grep works +// without a parser: +// +// grep -l 'labels:.*type/bug' .tea/issues/*.md +// grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it +package issue + +import ( + "fmt" + "regexp" + "strings" +) + +// Origin is "does this issue exist anywhere but here" — a fact about the work, +// so it is owned here. Its value is Local or a tracker's name; what that name +// means, and the handle that goes with it (gitea: owner/repo#42), stay foreign +// keys this layer carries but never reads. +const Local = "local" + +// DomainKeys are the metadata fields this layer owns, in render order. Foreign +// keys render after these, sorted, so the sync layer can add fields without +// touching this list. +var DomainKeys = []string{"id", "state", "labels", "assignees", "milestone", + "depends", "origin"} + +var listKeys = map[string]bool{"labels": true, "assignees": true, "depends": true} + +// States an issue may be in. +var States = []string{"open", "closed"} + +var slugOK = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +var slugPunct = regexp.MustCompile(`[^a-z0-9]+`) + +// Issue is one unit of work. Extra holds metadata this layer does not own. +type Issue struct { + ID string + Title string + Body string + State string + Labels []string + Assignees []string + Milestone string + Depends []string + Origin string + Extra map[string]string +} + +// IsLocal reports whether this issue exists nowhere but here. +// +// A complete state, not a pending one — and the state in which this file is +// the only copy of the work. An issue whose Origin names somewhere else can be +// fetched from there again; this one cannot. +func (i *Issue) IsLocal() bool { return i.Origin == Local } + +// Type is the value of the mandatory, exclusive type/* label. +func (i *Issue) Type() string { return i.namespaced("type/") } + +// Severity is the value of the optional, exclusive severity/* label. +func (i *Issue) Severity() string { return i.namespaced("severity/") } + +func (i *Issue) namespaced(prefix string) string { + for _, l := range i.Labels { + if v, ok := strings.CutPrefix(l, prefix); ok { + return v + } + } + return "" +} + +// FromText parses a stored issue. A non-empty id overrides the one in the +// metadata block, which is how the store makes the file name authoritative. +func FromText(text, id string) *Issue { + meta, title, body := ParseMeta(text) + + extra := map[string]string{} + for k, v := range meta { + if !isDomainKey(k) { + extra[k] = v + } + } + + if id == "" { + id = meta["id"] + } + milestone := meta["milestone"] + if milestone == "none" { + milestone = "" + } + state := meta["state"] + if state == "" { + state = "open" + } + origin := meta["origin"] + if origin == "" { + origin = Local + } + + return &Issue{ + ID: id, + Title: title, + Body: strings.TrimSpace(body), + State: state, + Labels: splitList(meta["labels"]), + Assignees: splitList(meta["assignees"]), + Milestone: milestone, + Depends: splitList(meta["depends"]), + Origin: origin, + Extra: extra, + } +} + +// Text renders the issue back to its canonical file form. +func (i *Issue) Text() string { + meta := map[string]string{} + for k, v := range i.Extra { + meta[k] = v + } + milestone := i.Milestone + if milestone == "" { + milestone = "none" + } + meta["id"] = i.ID + meta["state"] = i.State + meta["labels"] = renderList(i.Labels) + meta["assignees"] = renderList(i.Assignees) + meta["milestone"] = milestone + meta["depends"] = renderList(i.Depends) + meta["origin"] = i.Origin + + body := strings.TrimSpace(i.Body) + if body == "" { + body = "(no body)" + } + return fmt.Sprintf("%s\n# %s\n\n%s\n", RenderMeta(meta), i.Title, body) +} + +// Slugify turns a title into an id. Titles are English by format rule, so +// ASCII is enough; anything else is dropped rather than transliterated. +func Slugify(text string, maxLen int) string { + if maxLen <= 0 { + maxLen = 48 + } + s := strings.Trim(slugPunct.ReplaceAllString(strings.ToLower(text), "-"), "-") + if len(s) > maxLen { + cut := s[:maxLen] + if i := strings.LastIndex(cut, "-"); i > 0 { + cut = cut[:i] + } + s = cut + } + s = strings.Trim(s, "-") + if s == "" { + return "issue" + } + return s +} + +// IsSlug reports whether id is a well-formed identity. +func IsSlug(id string) bool { return slugOK.MatchString(id) } + +// UniqueID is base, or base-2, base-3… when the slug is already used. +func UniqueID(root, base string, taken []string) (string, error) { + used := map[string]bool{} + for _, t := range taken { + used[t] = true + } + for _, t := range AllIDs(root) { + used[t] = true + } + if !used[base] { + return base, nil + } + for n := 2; n < 1000; n++ { + cand := fmt.Sprintf("%s-%d", base, n) + if !used[cand] { + return cand, nil + } + } + return "", fmt.Errorf("cannot allocate an id for %q", base) +} + +func isDomainKey(k string) bool { + for _, d := range DomainKeys { + if d == k { + return true + } + } + return false +} diff --git a/cli/internal/issue/issue_test.go b/cli/internal/issue/issue_test.go new file mode 100644 index 0000000..50ca29f --- /dev/null +++ b/cli/internal/issue/issue_test.go @@ -0,0 +1,137 @@ +package issue + +import ( + "reflect" + "strings" + "testing" +) + +const sample = `--- +id: wire-sqlc-appclick +state: open +labels: [type/task, tech/sql] +assignees: [naudachu] +milestone: v0.2 +depends: [migrate-schema] +origin: gitea +gitea: claude-skills/tea#42 +synced: 2026-08-09T18:40:00Z +--- +# Wire sqlc into the appclick repo layer + +## Summary +Проводка sqlc. + +## Spec +none + +## Motivation +Ручной SQL расходится со схемой. + +## Acceptance criteria +- [x] сгенерирован код +- [ ] тесты зелёные +` + +func TestFromTextReadsTheDomainAndCarriesTheRest(t *testing.T) { + i := FromText(sample, "") + + if i.ID != "wire-sqlc-appclick" { + t.Errorf("id = %q", i.ID) + } + if i.Title != "Wire sqlc into the appclick repo layer" { + t.Errorf("title = %q", i.Title) + } + if want := []string{"type/task", "tech/sql"}; !reflect.DeepEqual(i.Labels, want) { + t.Errorf("labels = %v, want %v", i.Labels, want) + } + if i.Type() != "task" { + t.Errorf("type = %q", i.Type()) + } + if i.IsLocal() { + t.Error("origin gitea must not read as local") + } + if i.Extra["gitea"] != "claude-skills/tea#42" { + t.Errorf("foreign key lost: %v", i.Extra) + } + // The domain carries foreign keys; it must not learn to read them. + if _, ok := i.Extra["labels"]; ok { + t.Error("a domain key leaked into Extra") + } + if strings.Contains(i.Body, "# "+i.Title) { + t.Error("the title heading was left in the body") + } + if !strings.HasPrefix(i.Body, SummarySection) { + t.Errorf("body does not start at ## Summary: %q", head(i.Body)) + } +} + +func TestTextRoundTripsByteForByte(t *testing.T) { + if got := FromText(sample, "").Text(); got != sample { + t.Errorf("round trip changed the file:\n--- got ---\n%s\n--- want ---\n%s", got, sample) + } +} + +func TestMilestoneNoneIsTheEmptyMilestone(t *testing.T) { + i := FromText("---\nid: x\nmilestone: none\n---\n# T\n\nbody\n", "") + if i.Milestone != "" { + t.Errorf("milestone = %q, want empty", i.Milestone) + } + if !strings.Contains(i.Text(), "milestone: none") { + t.Error("an empty milestone must render back as none") + } +} + +func TestBareListValueIsTheSameStatementAsABracketedOne(t *testing.T) { + i := FromText("---\nid: x\nlabels: type/bug\n---\n# T\n\nbody\n", "") + if want := []string{"type/bug"}; !reflect.DeepEqual(i.Labels, want) { + t.Errorf("labels = %v, want %v", i.Labels, want) + } +} + +func TestFileNameWinsOverTheMetadataID(t *testing.T) { + // The store names the file after the slug, so a hand-edited `id:` that + // disagrees with it is the one that is wrong. + if got := FromText(sample, "renamed-by-hand").ID; got != "renamed-by-hand" { + t.Errorf("id = %q", got) + } +} + +func TestSlugify(t *testing.T) { + cases := map[string]string{ + // Truncation cuts back to the last dash, so a slug never ends in half + // a word — even when the limit happened to land on a boundary. + "Wire sqlc into the appclick repo layer": "wire-sqlc-into-the-appclick", + "Fix tea-guard crash": "fix-tea-guard-crash", + " Trailing --- dashes ": "trailing-dashes", + // Titles are English by format rule; anything else is dropped rather + // than transliterated, and an empty result is not an id. + "Крашится гвард": "issue", + "": "issue", + } + for in, want := range cases { + if got := Slugify(in, 32); got != want { + t.Errorf("Slugify(%q) = %q, want %q", in, got, want) + } + } +} + +func head(s string) string { + if len(s) > 40 { + return s[:40] + } + return s +} + +func TestSectionBodyStopsAtTheNextHeading(t *testing.T) { + body := "## Summary\nодин\nдва\n\n## Spec\nnone\n" + if got := SectionBody(body, "## Summary"); got != "один\nдва" { + t.Errorf("Summary = %q", got) + } + if got := SectionBody(body, SpecSection); got != "none" { + t.Errorf("Spec = %q", got) + } + if got := SectionBody(body, "## Missing"); got != "" { + t.Errorf("missing section = %q, want empty", got) + } +} diff --git a/cli/internal/issue/layering_test.go b/cli/internal/issue/layering_test.go new file mode 100644 index 0000000..ee420fe --- /dev/null +++ b/cli/internal/issue/layering_test.go @@ -0,0 +1,55 @@ +package issue + +import ( + "os/exec" + "strings" + "testing" +) + +// The domain must depend on nothing but the standard library and the one +// package that answers "which directory is the project". +// +// In Python this rule was a grep in a document and a habit; here it is a build +// graph, and the test fails the moment a tracker concept — an HTTP client, a +// JSON payload, a login — is imported into the layer that must not know a +// tracker exists. +func TestDomainDependsOnNothing(t *testing.T) { + const allowed = "git.noodles.cam/claude-skills/marketplace/cli/internal/project" + + out, err := exec.Command("go", "list", "-deps", ".").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, dep := range strings.Fields(string(out)) { + if dep == allowed || dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" { + continue + } + // A standard-library import path has no dot in its first element, + // because it has no domain name in front of it. + first, _, _ := strings.Cut(dep, "/") + if strings.Contains(first, ".") { + t.Errorf("the domain imports %s — a tracker concept in the layer that must not know one exists", dep) + } + } +} + +// The other half of the same rule: net/http and its friends are standard +// library, so "no third-party imports" would not catch a transport written by +// hand. Name them. +func TestDomainDoesNotReachTheNetworkOrTheShell(t *testing.T) { + forbidden := []string{"net/http", "net", "os/exec", "encoding/json"} + + out, err := exec.Command("go", "list", "-deps", ".").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + deps := map[string]bool{} + for _, d := range strings.Fields(string(out)) { + deps[d] = true + } + for _, f := range forbidden { + if deps[f] { + t.Errorf("the domain reaches %s — that belongs in the transport", f) + } + } +} diff --git a/cli/internal/issue/meta.go b/cli/internal/issue/meta.go new file mode 100644 index 0000000..c56f77e --- /dev/null +++ b/cli/internal/issue/meta.go @@ -0,0 +1,80 @@ +package issue + +import ( + "regexp" + "sort" + "strings" +) + +var titleRe = regexp.MustCompile(`^#[ \t]+(.+?)[ \t]*\n`) + +// ParseMeta splits a file into its metadata block, title, and body. +// +// Values come back as the raw text that followed the colon. Lists are not +// unpacked here: a foreign key that happens to look like a list must round +// trip byte for byte, and the domain's own lists are unpacked by their +// accessors. The title is the first `# ` heading below the block and is +// stripped out of the body. +func ParseMeta(text string) (meta map[string]string, title, body string) { + meta = map[string]string{} + rest := text + if strings.HasPrefix(text, "---") { + if end := strings.Index(text[3:], "\n---"); end != -1 { + end += 3 + for _, line := range strings.Split(strings.TrimSpace(text[3:end]), "\n") { + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + meta[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + rest = text[end+4:] + } + } + rest = strings.TrimLeft(rest, "\n") + + if m := titleRe.FindStringSubmatchIndex(rest); m != nil { + title = strings.TrimSpace(rest[m[2]:m[3]]) + rest = strings.TrimLeft(rest[m[1]:], "\n") + } + return meta, title, rest +} + +// RenderMeta writes the block back: domain keys in DomainKeys order, foreign +// keys after them, sorted. Lists stay on one line so grep sees them whole. +func RenderMeta(meta map[string]string) string { + var foreign []string + for k := range meta { + if !isDomainKey(k) { + foreign = append(foreign, k) + } + } + sort.Strings(foreign) + + lines := []string{"---"} + for _, k := range append(append([]string{}, DomainKeys...), foreign...) { + if v, ok := meta[k]; ok { + lines = append(lines, k+": "+v) + } + } + return strings.Join(append(lines, "---"), "\n") +} + +// splitList unpacks the inline `[a, b]` form, and a bare comma-separated value +// too: a hand-written `labels: type/bug` is the same statement as +// `labels: [type/bug]` and the format does not make an operator care. +func splitList(v string) []string { + v = strings.TrimSpace(v) + if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { + v = v[1 : len(v)-1] + } + var out []string + for _, part := range strings.Split(v, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + +func renderList(xs []string) string { return "[" + strings.Join(xs, ", ") + "]" } diff --git a/cli/internal/issue/store.go b/cli/internal/issue/store.go new file mode 100644 index 0000000..e6d1f87 --- /dev/null +++ b/cli/internal/issue/store.go @@ -0,0 +1,209 @@ +package issue + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/project" +) + +// The store holds two kinds of file, and only one of them is a store. +// +// An issue whose origin is Local lives here and nowhere else — that file IS the +// issue, and losing it loses the work. Anything with a tracker origin is a +// cache: the tracker has it, this copy is a working copy, and it is deleted the +// moment a push confirms the tracker is up to date. + +// Root resolves the issue store for the current project. An explicit out +// overrides it and is used exactly as typed: a relative out stays relative to +// the working directory, because that is what the operator asked for. +func Root(out string) string { + if out != "" { + return out + } + return project.StoreRoot("") +} + +// ErrStoreMissing marks the "the store directory is not there" failure. +// +// Deliberately a different answer from "the store is empty". One is a path that +// does not exist, the other is a repository with no issues filed yet, and +// conflating the two is exactly what made a missed directory look like an empty +// backlog. +var ErrStoreMissing = errors.New("store missing") + +// StoreExists reports whether root is a directory that can be read as a store. +func StoreExists(root string) bool { + if root == "" { + return false + } + fi, err := os.Stat(root) + return err == nil && fi.IsDir() +} + +// RequireStore asserts the store is there before reading or writing it. +// +// An empty root means no project was found at all — a different failure from a +// project whose store has not been created yet, and the message says so. +func RequireStore(root string) error { + if root == "" { + return fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError("")) + } + if !StoreExists(root) { + return fmt.Errorf("%w: store %s does not exist", ErrStoreMissing, root) + } + return nil +} + +// CreateStore creates the store, reporting whether it made the directory. +// +// Only the commands that legitimately bootstrap a store call this — `new` and +// `pull` — and both announce it. Nothing creates a store as a side effect of a +// write: a missing directory is something to report, not something to conjure. +// An unresolved root is never conjured either — without a marker there is no +// project to create a store IN, and guessing one is how a store once ended up +// inside the plugin. +func CreateStore(root string) (bool, error) { + if root == "" { + return false, fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError("")) + } + if StoreExists(root) { + return false, nil + } + if err := os.MkdirAll(root, 0o755); err != nil { + return false, err + } + return true, nil +} + +// StoreError says why root cannot be read as a store, or nil when it holds +// issues. +// +// The three messages are distinct on purpose — no project at all, a project +// with no store, and a store with nothing in it are three different things to +// do next. +func StoreError(root string) error { + switch { + case root == "": + return project.NotFoundError("") + case !StoreExists(root): + return fmt.Errorf("store %s does not exist — nothing was created; pass --out to point elsewhere", root) + case len(AllIDs(root)) == 0: + return fmt.Errorf("store %s exists but is empty", root) + } + return nil +} + +// PathOf is where the issue with this id lives. +func PathOf(root, id string) string { return filepath.Join(root, id+".md") } + +// AllIDs lists every issue in the store, by slug. +// +// An issue file is named by its slug and a slug has no dot in it, so +// `.comments.md` — the thread the sync layer parks beside an issue — is not +// one, and neither is anything else that grew a second extension. Without that +// rule `wire-sqlc.comments` reads as an issue called `wire-sqlc.comments`, and +// a bare push tries to file the comment thread as a unit of work. +func AllIDs(root string) []string { + if !StoreExists(root) { + return nil + } + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".md") { + continue + } + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "INDEX") || + strings.HasPrefix(name, "tree-") { + continue + } + id := name[:len(name)-3] + if strings.Contains(id, ".") { + continue + } + out = append(out, id) + } + sort.Strings(out) + return out +} + +// SlugFiles lists every file the store holds under one slug — the issue and its +// sidecars. +// +// `.md` is the issue. Anything named `.` beside it is a +// companion another layer parked there (`.comments.md` is the one that +// exists today). AllIDs already refuses to read those as issues because a slug +// has no dot in it; this is the same rule read the other way round. +// +// Which is how the domain can remove an issue completely without learning what +// any of those companions are: it does not need to know that a comment thread +// exists to know that a file named after this issue belongs to it and goes when +// it goes. The issue's own file comes first — it is the headline of any receipt +// printed from this list. +// +// A missing store is an empty list, not an error: nothing is there to remove. +func SlugFiles(root, id string) []string { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + prefix, own := id+".", id+".md" + var self, sidecars []string + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, prefix) || e.IsDir() { + continue + } + p := filepath.Join(root, name) + if name == own { + self = append(self, p) + } else { + sidecars = append(sidecars, p) + } + } + sort.Strings(sidecars) + return append(self, sidecars...) +} + +// Load reads one issue. The file name wins over the id in the metadata block. +func Load(root, id string) (*Issue, error) { + raw, err := os.ReadFile(PathOf(root, id)) + if err != nil { + return nil, err + } + return FromText(string(raw), id), nil +} + +// LoadAll reads the whole store. +func LoadAll(root string) (map[string]*Issue, error) { + out := map[string]*Issue{} + for _, id := range AllIDs(root) { + i, err := Load(root, id) + if err != nil { + return nil, err + } + out[id] = i + } + return out, nil +} + +// Save writes an issue to the store, which must already exist. +func Save(root string, i *Issue) (string, error) { + if err := RequireStore(root); err != nil { + return "", err + } + p := PathOf(root, i.ID) + if err := os.WriteFile(p, []byte(i.Text()), 0o644); err != nil { + return "", err + } + return p, nil +} diff --git a/cli/internal/issue/taxonomy.go b/cli/internal/issue/taxonomy.go new file mode 100644 index 0000000..fac2ac8 --- /dev/null +++ b/cli/internal/issue/taxonomy.go @@ -0,0 +1,116 @@ +package issue + +import "strings" + +// Four namespaces classify an issue. type/* is mandatory and exclusive, +// severity/* is optional and exclusive, tech/* and comp/* are free-form. +// +// Colors are NOT here — a hex code is how a tracker paints a chip, which makes +// it the sync layer's business. + +// Types are the kinds of work, and the order is the order they are offered in. +var Types = []struct{ Name, Meaning string }{ + {"bug", "Something behaves incorrectly in existing code"}, + {"task", "Implementation of new functionality"}, + {"refactor", "Internal restructuring; behavior must not change"}, + {"test", "Writing or fixing tests"}, + {"feature", "Container: several issues delivering one unit of business value"}, + {"draft", "Idea captured for later; not ready for work"}, +} + +// Severities are the business-impact levels, ascending. +var Severities = []string{"low", "medium", "high", "showstopper", "critical"} + +// Section headers are fixed English literals in a fixed order; only body prose +// is Russian. +const ( + SummarySection = "## Summary" + SpecSection = "## Spec" + ACSection = "## Acceptance criteria" + DependsSection = "## Depends on" + IssuesSection = "## Issues" +) + +// RequiredSections must be present in every type. type/draft is exempt from +// acceptance criteria and only from that. +var RequiredSections = []string{SummarySection, SpecSection} + +// DepSections both name what an issue depends on, so both are edge sources and +// both point the same way. In a type/feature that reads container -> child: +// "the container is closed when its children are closed" IS a dependency. +// "a child belongs to a feature" is membership, and membership has no place in +// a dependency graph — which is why a child never names its container back. +var DepSections = []string{DependsSection, IssuesSection} + +// ExpectedSections are the per-type sections from the templates. Absence is a +// warning, not a stop. +var ExpectedSections = map[string][]string{ + "bug": {"## Steps to reproduce", "## Expected", "## Actual", "## Environment"}, + "task": {"## Motivation"}, + "refactor": {"## Motivation", "## Invariants"}, + "test": {"## Motivation", "## Test cases"}, + "feature": {"## Motivation", IssuesSection}, + "draft": {"## Notes"}, +} + +// KnownType reports whether name is one of Types. +func KnownType(name string) bool { + for _, t := range Types { + if t.Name == name { + return true + } + } + return false +} + +// KnownSeverity reports whether name is one of Severities. +func KnownSeverity(name string) bool { + for _, s := range Severities { + if s == name { + return true + } + } + return false +} + +// TypeNames lists the type slugs, for error messages and completion. +func TypeNames() []string { + out := make([]string, len(Types)) + for i, t := range Types { + out[i] = t.Name + } + return out +} + +// CanonicalLabels is the label set a tracker needs before a push can attach +// anything: the two exclusive namespaces in full. tech/* and comp/* are +// project-specific and have no preset. +func CanonicalLabels() []string { + out := make([]string, 0, len(Types)+len(Severities)) + for _, t := range Types { + out = append(out, "type/"+t.Name) + } + for _, s := range Severities { + out = append(out, "severity/"+s) + } + return out +} + +// SectionBody is the text under header, up to the next `## ` heading. +func SectionBody(body, header string) string { + var out []string + active := false + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "## ") { + if active { + break + } + active = strings.TrimSpace(line) == header + continue + } + if active { + out = append(out, line) + } + } + return strings.TrimSpace(strings.Join(out, "\n")) +} diff --git a/cli/internal/issue/template.go b/cli/internal/issue/template.go new file mode 100644 index 0000000..fd31280 --- /dev/null +++ b/cli/internal/issue/template.go @@ -0,0 +1,132 @@ +package issue + +import "strings" + +// The type templates, verbatim from references/format.md. +// +// Section headers are fixed English literals in a fixed order; body prose is +// Russian. Both halves of that rule are in the strings below, and the format +// document is the source of truth for them. + +const specSection = "## Spec\nnone\n" + +var templates = map[string]string{ + "bug": `## Summary +Что сломано и где проявляется, одно-два предложения. + +` + specSection + ` +## Steps to reproduce +1. … +2. … + +## Expected +Что должно было произойти. + +## Actual +Что происходит на самом деле: вывод команды, лог. + +## Environment +Только релевантное: версии, ОС, конфигурация. + +## Acceptance criteria +- [ ] баг не воспроизводится по шагам выше +- [ ] добавлена проверка на регрессию (если применимо) +`, + "task": `## Summary +Что нужно сделать, одно-два предложения. + +` + specSection + ` +## Motivation +Какую проблему пользователя/системы это решает. + +## Acceptance criteria +- [ ] проверяемое условие +- [ ] … +`, + "refactor": `## Summary +Что перестраиваем и в каких файлах (` + "`path/file:line`" + `). + +` + specSection + ` +## Motivation +Чем плохо текущее состояние: дублирование, связность, читаемость. + +## Invariants +Что НЕ должно измениться: поведение, публичные API, форматы данных. + +## Acceptance criteria +- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …) +`, + "test": `## Summary +Что покрываем тестами и где (` + "`path/file:line`" + `). + +` + specSection + ` +## Motivation +Зачем: регрессия после бага, пробел в покрытии, флаки-тест. + +## Test cases +- сценарий → ожидаемый результат +- … + +## Acceptance criteria +- [ ] перечисленные кейсы покрыты и зелёные +- [ ] тесты проходят в CI +`, + "feature": `## Summary +Бизнес-ценность одним-двумя предложениями. + +` + specSection + ` +## Motivation +Какую проблему пользователя/системы это решает. + +## Issues +- [ ] slug-дочернего-issue — краткое описание части +- [ ] … + +## Acceptance criteria +- [ ] все дочерние issues закрыты +- [ ] проверяемое условие уровня фичи +`, + "draft": `## Summary +Идея одним-двумя предложениями. + +` + specSection + ` +## Notes +Свободные заметки: что известно, открытые вопросы, варианты. +`, +} + +// Template is the prefilled body for a type, with `## Depends on` inserted +// right after `## Spec` when the issue has dependencies. +func Template(typ string, depends []string) string { + return withDepends(templates[typ], depends) +} + +// withDepends places the section where the format says it goes: after +// `## Spec`, before everything else. Appended at the end only when the +// template has no third section to sit in front of. +func withDepends(body string, depends []string) string { + if len(depends) == 0 { + return body + } + var b strings.Builder + b.WriteString("## Depends on\n") + for _, d := range depends { + b.WriteString("- " + d + "\n") + } + block := b.String() + + var out []string + placed := false + for _, line := range strings.SplitAfter(body, "\n") { + if !placed && len(out) > 0 && strings.HasPrefix(line, "## ") && + !strings.HasPrefix(line, SummarySection) && !strings.HasPrefix(line, SpecSection) { + out = append(out, block+"\n") + placed = true + } + out = append(out, line) + } + if !placed { + out = append(out, "\n"+block) + } + return strings.Join(out, "") +} diff --git a/cli/internal/issue/validate.go b/cli/internal/issue/validate.go new file mode 100644 index 0000000..ee87838 --- /dev/null +++ b/cli/internal/issue/validate.go @@ -0,0 +1,131 @@ +package issue + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + titlePrefixRe = regexp.MustCompile( + `(?i)^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)`) + cyrillicRe = regexp.MustCompile(`(?i)[а-яё]`) +) + +// Validate reports what is wrong with an issue. +// +// Errors mean the issue is not well-formed in the canonical format; warnings +// mean it deviates from its type template. Pass knownIDs to have dependencies +// resolved against a store; pass nil to skip that check. +func Validate(i *Issue, knownIDs map[string]bool) (errs, warns []string) { + switch { + case i.ID == "": + errs = append(errs, "no `id:` — the slug is the issue's identity") + case !IsSlug(i.ID): + errs = append(errs, fmt.Sprintf("id %q is not a slug (lowercase, digits, single dashes)", i.ID)) + } + + if !contains(States, i.State) { + errs = append(errs, fmt.Sprintf("state %q must be one of: %s", + i.State, strings.Join(States, ", "))) + } + + var types []string + severities := 0 + for _, l := range i.Labels { + if strings.HasPrefix(l, "type/") { + types = append(types, l) + } + if strings.HasPrefix(l, "severity/") { + severities++ + } + } + switch { + case len(types) != 1: + found := strings.Join(types, ", ") + if found == "" { + found = "none" + } + errs = append(errs, fmt.Sprintf("need exactly one type/* label, found %d: %s", + len(types), found)) + case !KnownType(i.Type()): + errs = append(errs, fmt.Sprintf("unknown type %q — known: %s", + i.Type(), strings.Join(TypeNames(), ", "))) + } + if severities > 1 { + errs = append(errs, "at most one severity/* label") + } + if s := i.Severity(); s != "" && !KnownSeverity(s) { + warns = append(warns, fmt.Sprintf("unknown severity %q", s)) + } + + if i.Title == "" { + errs = append(errs, "no `# Title` heading below the metadata block") + } else { + if titlePrefixRe.MatchString(i.Title) { + head := i.Title + if len(head) > 24 { + head = head[:24] + } + errs = append(errs, fmt.Sprintf( + "title carries a type prefix (%q) — the type lives in the label", head)) + } + if cyrillicRe.MatchString(i.Title) { + errs = append(errs, "title must be English, imperative mood (prose stays Russian)") + } + } + + for _, h := range RequiredSections { + if !strings.Contains(i.Body, h) { + errs = append(errs, "missing section "+h) + } + } + if i.Type() != "draft" && !strings.Contains(i.Body, ACSection) { + errs = append(errs, "missing section "+ACSection) + } + if strings.Contains(i.Body, SpecSection) && SectionBody(i.Body, SpecSection) == "" { + errs = append(errs, "## Spec is empty — put a repo path, a URL, or the literal `none`") + } + + for _, h := range ExpectedSections[i.Type()] { + if !strings.Contains(i.Body, h) { + warns = append(warns, fmt.Sprintf("type/%s template usually has %s", i.Type(), h)) + } + } + + if contains(i.Depends, i.ID) { + errs = append(errs, "depends on itself") + } + if knownIDs != nil { + for _, d := range i.Depends { + if !knownIDs[d] { + warns = append(warns, fmt.Sprintf("depends on %q, which is not in the store", d)) + } + } + } + + // `depends:` is the machine-readable graph; the body section is prose for + // humans. They drift silently unless something says so. Name the section + // the reference actually came from — for a container that is `## Issues`. + for _, r := range BodyDepRefs(i.Body) { + if !strings.HasPrefix(r.Ref, "#") && !contains(i.Depends, r.Ref) { + warns = append(warns, fmt.Sprintf( + "%s mentions %q but `depends:` does not list it", r.Section, r.Ref)) + } + } + + // An unticked checkbox is never a finding — neither an error nor a warning. + // `- [ ]` is work not done yet, which is the normal state of a perfectly + // well-formed issue. Reading that state is the `ac` command's job. + + return errs, warns +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} diff --git a/cli/internal/mapping/frompayload.go b/cli/internal/mapping/frompayload.go new file mode 100644 index 0000000..fd1d3f5 --- /dev/null +++ b/cli/internal/mapping/frompayload.go @@ -0,0 +1,207 @@ +package mapping + +import ( + "slices" + "strconv" + "strings" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +// Gitea -> domain. + +// PayloadOptions are the things a caller knows and this package cannot: what +// the store already holds, what the tracker's numbers mean locally, and what +// time it is. +type PayloadOptions struct { + // IDForNumber maps a Gitea number to a local slug. A dependency whose + // target has not been pulled yet is dropped from `depends:` rather than + // invented — the body still names it, so nothing is lost, and a made-up + // slug would be an edge to a file that does not exist. + IDForNumber map[int]string + // ExtraNumbers are dependencies the caller learned somewhere other than the + // body, folded in with the ones the body names. + ExtraNumbers []int + // Synced is the timestamp stamped into `synced:`. The clock belongs to the + // caller: a package with a clock in it is not a pure one. + Synced string + // LocalBody is the body of the copy already in the store, when there is + // one. It contributes exactly one thing — its ticked checkboxes survive the + // overwrite. Empty is what a first pull passes. + LocalBody string +} + +// FromPayload builds a domain issue from a Gitea issue payload, and returns the +// numbers it could not resolve to a slug. +// +// The id marker is stripped before anything else looks at the body: it is +// transport bookkeeping, and the caller has already read the slug off it to +// decide which id to pass. Everything downstream — checkboxes, `#N` references, +// what lands on disk — sees the body the author wrote. +func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) { + body := MergeCheckboxState(StripIDMarker(strings.TrimSpace(p.Body)), opt.LocalBody) + + numbers := NumbersInBody(body) + for _, n := range opt.ExtraNumbers { + if !slices.Contains(numbers, n) { + numbers = append(numbers, n) + } + } + + // A number that resolves to this issue itself is dropped without a word: a + // body may well name its own number, and a self-edge is a cycle the graph + // would report as an error the author cannot fix. + var deps []string + var unresolved []int + for _, n := range numbers { + slug := opt.IDForNumber[n] + switch { + case slug != "" && slug != id && !slices.Contains(deps, slug): + deps = append(deps, slug) + case slug == "": + unresolved = append(unresolved, n) + } + } + + // The repository the caller asked for, never the one the payload names: a + // dependency listing answers with issues from elsewhere, and this is the + // handle for the copy landing in THIS store. + extra := map[string]string{ + GiteaKey: wire.Key{Repo: repo, Number: p.Number}.String(), + URLKey: p.HTMLURL, + SyncedKey: opt.Synced, + } + if p.Ref != "" { + extra[BranchKey] = p.Ref + } + if p.UpdatedAt != "" { + extra[RemoteUpdatedKey] = p.UpdatedAt + } + // Zero comments is not a fact worth a line in the file — every issue that + // has never been discussed would carry one. + if p.Comments > 0 { + extra[CommentsKey] = strconv.Itoa(p.Comments) + } + + state := p.State + if state == "" { + state = "open" + } + + // Appended into nil slices, so an issue with no labels is the same value as + // one loaded from a file — the store's own parser yields nothing, not an + // empty list, and two spellings of "none" is a comparison bug waiting. + var labels []string + for _, l := range p.Labels { + labels = append(labels, l.Name) + } + var assignees []string + for _, a := range p.Assignees { + assignees = append(assignees, a.Login) + } + milestone := "" + if p.Milestone != nil { + milestone = p.Milestone.Title + } + + return &issue.Issue{ + ID: id, + Title: p.Title, + Body: body, + State: state, + Labels: labels, + Assignees: assignees, + Milestone: milestone, + Depends: deps, + Origin: Origin, + Extra: extra, + }, unresolved +} + +// NumbersInBody is every `#N` referenced from the body's dependency sections. +// Used only to seed `depends:` on the first pull — after that the metadata +// field is the graph and the prose is prose. +func NumbersInBody(body string) []int { + var out []int + for _, ref := range issue.BodyDepRefs(body) { + if !strings.HasPrefix(ref.Ref, "#") { + continue + } + if n, err := strconv.Atoi(ref.Ref[1:]); err == nil { + out = append(out, n) + } + } + return out +} + +// MergeCheckboxState is the remote body with every tick the local copy already +// had put back. +// +// The one exception to "a pull overwrites the body", and deliberately the +// narrowest one that works. A tick is MONOTONE — an item only ever travels +// `[ ]` -> `[x]` — so the two sides are joined by a set union, not reconciled: +// no base version, no drift tracking, no conflict to resolve. The set is a set +// of item TEXTS, and an item comes out ticked when either side has it ticked. +// Everything else in the body is still the remote's word. +// +// Matching is on Checkbox.Text, which the domain parser has already stripped +// and rejoined with single spaces, so rewrapping a long item does not cost it +// its tick. It is otherwise literal: reword an item and it is a different item +// — the tick stays with the wording it was put on. +// +// THE SAME TEXT MORE THAN ONCE is read as the rule says, as a set: one ticked +// local item ticks every remote item with that text. The alternative — pairing +// duplicates up by order — is the reading that can still drop a tick (local +// `[ ]` then `[x]`, remote a single line: the ticked one pairs with nothing), +// and dropping a tick is the bug this exists to fix. Two items whose text is +// identical are the same item to whoever reads them. +// +// The price, accepted explicitly: UNticking is not monotone, so a box unticked +// in the web UI comes back on the next pull. Untick locally, push. +func MergeCheckboxState(remoteBody, localBody string) string { + ticked := map[string]bool{} + for _, c := range issue.Checkboxes(localBody) { + if c.Checked { + ticked[c.Text] = true + } + } + if len(ticked) == 0 { + return remoteBody + } + body := remoteBody + // SetCheckbox trades one character for one character, so line numbers read + // off remoteBody stay valid against the partially rewritten body. + for _, c := range issue.Checkboxes(remoteBody) { + if c.Checked || !ticked[c.Text] { + continue + } + // The line was just read off remoteBody by the same parser, so this + // cannot fail; if it ever did, one unticked item is a smaller loss than + // abandoning the merge and dropping every other tick with it. + if next, err := issue.SetCheckbox(body, c.Line, true); err == nil { + body = next + } + } + return body +} + +// RenderComments flattens a comment thread to markdown. Read-only: nothing +// writes it back, which is why it may be as lossy as a reader needs. +func RenderComments(comments []wire.Comment) string { + var out []string + for _, c := range comments { + day := c.CreatedAt + if len(day) > 10 { + day = day[:10] + } + body := strings.TrimSpace(c.Body) + if body == "" { + body = "(empty)" + } + out = append(out, + "## comment "+strconv.FormatInt(c.ID, 10)+" — "+c.User.Login+" — "+day, + "", body, "") + } + return strings.Join(out, "\n") +} diff --git a/cli/internal/mapping/labels.go b/cli/internal/mapping/labels.go new file mode 100644 index 0000000..deda354 --- /dev/null +++ b/cli/internal/mapping/labels.go @@ -0,0 +1,123 @@ +package mapping + +import ( + "strings" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what +// an issue IS, which is exactly why the table lives here and not in the domain +// — internal/issue/taxonomy.go says as much where the labels themselves are. +// +// The keys are the canonical set and nothing else. TestEveryCanonicalLabelHasA +// Color walks issue.CanonicalLabels() and fails on a gap, so a type or a +// severity added over there cannot quietly arrive here as grey. +var labelColors = map[string]string{ + "type/bug": "#ee0701", + "type/task": "#0e8a16", + "type/refactor": "#1d76db", + "type/test": "#fbca04", + "type/feature": "#5319e7", + "type/draft": "#cccccc", + "severity/low": "#c2e0c6", + "severity/medium": "#fbca04", + "severity/high": "#eb6420", + "severity/showstopper": "#ee0701", + "severity/critical": "#b60205", +} + +// DefaultColor paints everything outside the canonical set. `tech/*` and +// `comp/*` are project-specific and have no preset, so guessing a color for one +// would be inventing a meaning it does not have. +const DefaultColor = "#ededed" + +// LabelColor is the hex code a label is painted with in the tracker. +func LabelColor(name string) string { + if c, ok := labelColors[name]; ok { + return c + } + return DefaultColor +} + +// LabelSpecs is the request body for each name, in the order given. +// +// A wire.LabelRequest and not a shape of this package's own: it is field for +// field what a label create takes, and a second spelling of it would mean the +// bootstrap command copying four fields across on its way to the transport. +// Exclusivity and meaning come from the domain taxonomy; only the color is +// decided here. +// +// A slice and not a map: the order is the taxonomy's, and a bootstrap prints +// its plan in that order — a map would shuffle the plan on every run and make +// two identical runs look like different ones. +func LabelSpecs(names []string) []wire.LabelRequest { + ns := exclusiveNamespaces() + out := make([]wire.LabelRequest, 0, len(names)) + for _, name := range names { + out = append(out, wire.LabelRequest{ + Name: name, + Color: LabelColor(name), + Description: typeMeaning(name), + Exclusive: hasAnyPrefix(name, ns), + }) + } + return out +} + +// CanonicalLabelSpecs is the set a repository needs before a push can attach +// anything. +// +// Derived from the domain's own list rather than restated: add a type over in +// the taxonomy and the next bootstrap creates it, with no line changing here +// except the color it is painted with. +func CanonicalLabelSpecs() []wire.LabelRequest { return LabelSpecs(issue.CanonicalLabels()) } + +// exclusiveNamespaces are the namespaces at most one label may come from, read +// off the canonical set rather than listed again — the domain publishes exactly +// the exclusive namespaces there, in full, and that is what makes the set +// canonical. +// +// A prefix test and not a membership test, on purpose: a project's own +// `type/spike` is still exclusive. Being one of a set of alternatives is a +// property of the namespace, not of the members the taxonomy happens to know. +func exclusiveNamespaces() []string { + var out []string + seen := map[string]bool{} + for _, name := range issue.CanonicalLabels() { + ns, _, ok := strings.Cut(name, "/") + if !ok || seen[ns] { + continue + } + seen[ns] = true + out = append(out, ns+"/") + } + return out +} + +// typeMeaning is the description a `type/*` label carries into the tracker, so +// the meaning a reader needs is on the chip rather than in this repository. +// Nothing else gets one: a severity explains itself, and a project's own +// namespaces are not ours to describe. +func typeMeaning(name string) string { + tail, ok := strings.CutPrefix(name, "type/") + if !ok { + return "" + } + for _, t := range issue.Types { + if t.Name == tail { + return t.Meaning + } + } + return "" +} + +func hasAnyPrefix(s string, prefixes []string) bool { + for _, p := range prefixes { + if strings.HasPrefix(s, p) { + return true + } + } + return false +} diff --git a/cli/internal/mapping/labels_test.go b/cli/internal/mapping/labels_test.go new file mode 100644 index 0000000..75abc78 --- /dev/null +++ b/cli/internal/mapping/labels_test.go @@ -0,0 +1,95 @@ +package mapping + +import ( + "regexp" + "testing" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" +) + +var hexColor = regexp.MustCompile(`^#[0-9a-f]{6}$`) + +// The canonical set is the domain's, and every member of it must have a color +// here. A type added over in the taxonomy that arrived as grey would look like +// a label somebody created by hand. +func TestEveryCanonicalLabelHasAColor(t *testing.T) { + for _, name := range issue.CanonicalLabels() { + color := LabelColor(name) + switch { + case color == DefaultColor: + t.Errorf("%s has no color of its own", name) + case !hexColor.MatchString(color): + t.Errorf("%s = %q, want #rrggbb in lower case", name, color) + } + } + // And the other direction: a color left behind after a label was retired + // paints nothing and is a lie about what the taxonomy holds. + if len(labelColors) != len(issue.CanonicalLabels()) { + t.Errorf("%d colors for %d canonical labels — one of the two lists moved without the other", + len(labelColors), len(issue.CanonicalLabels())) + } + // Anything outside the set is project-specific and nobody here can guess + // what it means. + if got := LabelColor("tech/sql"); got != DefaultColor { + t.Errorf("LabelColor(tech/sql) = %q, want the default", got) + } +} + +func TestLabelSpecs(t *testing.T) { + cases := []struct { + name string + description string + exclusive bool + }{ + {"type/bug", "Something behaves incorrectly in existing code", true}, + {"type/draft", "Idea captured for later; not ready for work", true}, + {"severity/critical", "", true}, + // Exclusivity is a property of the namespace, not of the members the + // taxonomy happens to know. + {"type/spike", "", true}, + {"tech/sql", "", false}, + {"comp/appclick", "", false}, + } + names := make([]string, len(cases)) + for i, c := range cases { + names[i] = c.name + } + + specs := LabelSpecs(names) + if len(specs) != len(cases) { + t.Fatalf("%d specs for %d names", len(specs), len(cases)) + } + for i, c := range cases { + got := specs[i] + // The order is the taxonomy's: a bootstrap prints its plan in it, and + // two identical runs must not look like different ones. + if got.Name != c.name { + t.Fatalf("spec %d is %s, want %s", i, got.Name, c.name) + } + if got.Description != c.description { + t.Errorf("%s description = %q, want %q", c.name, got.Description, c.description) + } + if got.Exclusive != c.exclusive { + t.Errorf("%s exclusive = %v, want %v", c.name, got.Exclusive, c.exclusive) + } + if got.Color != LabelColor(c.name) { + t.Errorf("%s color = %q", c.name, got.Color) + } + } +} + +func TestCanonicalLabelSpecsAreTheDomainsList(t *testing.T) { + specs := CanonicalLabelSpecs() + want := issue.CanonicalLabels() + if len(specs) != len(want) { + t.Fatalf("%d specs, want %d", len(specs), len(want)) + } + for i, name := range want { + if specs[i].Name != name { + t.Errorf("spec %d = %s, want %s", i, specs[i].Name, name) + } + if !specs[i].Exclusive { + t.Errorf("%s must be exclusive — the canonical set IS the exclusive namespaces", name) + } + } +} diff --git a/cli/internal/mapping/layering_test.go b/cli/internal/mapping/layering_test.go new file mode 100644 index 0000000..8d74f8a --- /dev/null +++ b/cli/internal/mapping/layering_test.go @@ -0,0 +1,46 @@ +package mapping + +import ( + "os/exec" + "strings" + "testing" +) + +// The bridge translates values and nothing else: no network, no filesystem, no +// clock, no configuration. Every one of those is a caller's to supply, which is +// what lets this package be reasoned about and tested without a Gitea anywhere. +// +// Two imports and no more: internal/issue for what an issue is, and +// internal/wire for the shapes on the other side. wire is allowed precisely +// because it is inert — shapes and identifiers over the standard library, with +// a layering test of its own — so naming a payload here costs nothing and +// reaches nowhere. +// +// DIRECT imports, not the dependency walk internal/issue does. The domain +// reaches os through internal/project and that is the domain's business; what +// this test is about is what this package itself reaches for. A transport that +// grew a helper here — or a lookup that quietly opened a config file — is what +// it catches. +func TestTheBridgeTranslatesAndNothingElse(t *testing.T) { + forbidden := map[string]string{ + "net/http": "an HTTP call belongs in the transport", + "net": "an HTTP call belongs in the transport", + "os": "a pure function reads no file and no environment", + "os/exec": "nothing here shells out", + "io/ioutil": "a pure function reads no file", + "time": "the clock is the caller's; a timestamp arrives as a string", + "git.noodles.cam/claude-skills/marketplace/cli/internal/gitea": "the transport imports this package, never the reverse", + "git.noodles.cam/claude-skills/marketplace/cli/internal/config": "credentials and repositories are the transport's", + "git.noodles.cam/claude-skills/marketplace/cli/internal/project": "nothing here resolves a path", + } + + out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, dep := range strings.Fields(string(out)) { + if why, bad := forbidden[dep]; bad { + t.Errorf("mapping imports %s — %s", dep, why) + } + } +} diff --git a/cli/internal/mapping/mapping.go b/cli/internal/mapping/mapping.go new file mode 100644 index 0000000..f67c72c --- /dev/null +++ b/cli/internal/mapping/mapping.go @@ -0,0 +1,106 @@ +// Package mapping is md <-> Gitea JSON. The whole translation, and only the +// translation. +// +// Pure functions: no network, no filesystem, no flags, no clock. Give it a +// payload and it hands back a domain issue; give it an issue and it hands back +// a request body. That purity is the point — it can be reasoned about and +// tested without a Gitea anywhere, and it is the one package to open when the +// two representations disagree. +// +// Direction of knowledge: this package imports the domain and the protocol +// (internal/wire), and nothing imports it but the command layer. The domain +// never imports it, and TestDomainDependsOnNothing over in internal/issue fails +// the moment it does; the transport never imports it either, and +// TestTransportDoesNotImportTheDomain over in internal/gitea says so. Both +// sides speak wire's shapes, which is what lets the two meet without either one +// reaching into the other. +// +// What crosses the boundary, and what does not: +// +// domain Gitea note +// ---------------------------------------------------------------------- +// id (slug) body marker , first line of the +// tracker-side body; stripped out of the +// local copy — see marker.go +// title title verbatim, both ways +// body body verbatim up, verbatim down except the +// marker and checkbox state +// state state open/closed, the same vocabulary +// labels labels[] names both ways; ids only on write +// assignees assignees[] logins +// milestone milestone.title resolved to an id on write +// depends — slugs; #N is translated at this edge +// — number, html_url lands in Extra as gitea:/url: +// — ref Extra as branch:; push fills it from git +// +// `depends:` is the authoritative graph and is always slugs. The body's +// `## Depends on` section is human prose and is passed through UNCHANGED in +// both directions: a pull seeds `depends:` from the `#N` it finds there, and a +// push never rewrites what the author wrote. Deliberate — a translator that +// edits prose churns the body on every round trip. +// +// The ONE thing this package adds to a body is the id marker, and it does so +// because the slug has to survive a push: push deletes the local file, so the +// tracker has to be the thing that remembers what the issue was called here. +package mapping + +import ( + "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +// Origin is what this bridge writes into the domain's `origin:` field. The +// domain records that an issue exists somewhere else; only this layer knows +// where, and what the handle beside it means. +const Origin = "gitea" + +// The sync-owned metadata fields, named once. Every one of them is bookkeeping +// about a tracker, which is why the domain carries them verbatim in +// Issue.Extra and never reads them — the format's ownership table draws the +// same line. A field spelled in three call sites is a field that gets renamed +// in two. +const ( + // GiteaKey is the handle in the tracker: owner/repo#42, a wire.Key written + // out. Cross-repo on purpose — a number alone is only unique inside one + // repository, and an issue that has been moved, or a store that has ever + // pointed at two repositories, needs the answer to say which. + GiteaKey = "gitea" + // URLKey is the issue's web address, for a receipt a human can click. + URLKey = "url" + // SyncedKey is when this copy was last written from or to the tracker — + // how old the working copy is, and nothing more. + SyncedKey = "synced" + // RemoteUpdatedKey is the tracker's own updated_at. + RemoteUpdatedKey = "remote-updated" + // CommentsKey is how many comments the tracker holds, so a reader knows a + // thread exists without fetching it. + CommentsKey = "comments" + // BranchKey is Gitea's `ref` — the branch an issue is pinned to. Its value + // is a git branch name and means exactly `ref`, which is what makes it a + // sync field rather than a domain one. + BranchKey = "branch" +) + +// RemoteKeyOf is the handle an issue carries, and whether it carries one at +// all. +// +// ok is false for anything that is not a handle: an empty field on a +// never-pushed issue, a line somebody hand-edited, a key written by a format +// that predates this one — and a bare `#42`, which names a number without the +// repository that makes it mean something. Callers act on ok rather than on a +// zero number, because "#0" and "not synced" would otherwise be the same +// answer. +func RemoteKeyOf(i *issue.Issue) (key wire.Key, ok bool) { + k, err := wire.ParseKey(i.Extra[GiteaKey]) + if err != nil || k.Repo.Zero() { + return wire.Key{}, false + } + return k, true +} + +// NumberOf is the Gitea number of an already-synced issue; ok is false for one +// that has never been pushed. +func NumberOf(i *issue.Issue) (number int, ok bool) { + k, ok := RemoteKeyOf(i) + return k.Number, ok +} diff --git a/cli/internal/mapping/mapping_test.go b/cli/internal/mapping/mapping_test.go new file mode 100644 index 0000000..aff7aa9 --- /dev/null +++ b/cli/internal/mapping/mapping_test.go @@ -0,0 +1,381 @@ +package mapping + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +// The repository the fixtures are pushed to. A wire.Repo and not a string: the +// handle in `gitea:` is a key, and a key is a repository and a number. +var tea = wire.Repo{Owner: "claude-skills", Name: "tea"} + +// A file exactly as the store holds it: domain fields, then the sync fields the +// domain carries and never reads. +const stored = `--- +id: wire-sqlc-appclick +state: open +labels: [type/task, tech/sql] +assignees: [naudachu] +milestone: v0.2 +depends: [migrate-schema] +origin: gitea +branch: feat/wire-sqlc +gitea: claude-skills/tea#42 +synced: 2026-08-09T18:40:00Z +--- +# Wire sqlc into the appclick repo layer + +## Summary +Проводка sqlc в слой репозиториев. + +## Spec +none + +## Depends on +- #7 — нужна схема БД из этого issue + +## Acceptance criteria +- [x] сгенерирован код +- [ ] тесты зелёные +` + +func ptr[T any](v T) *T { return &v } + +func roundTripOptions() RequestOptions { + return RequestOptions{ + LabelIDs: map[string]int64{"type/task": 11, "tech/sql": 12}, + MilestoneID: ptr(int64(5)), + IncludeState: true, + } +} + +// The whole point of the package in one test: everything the format says is +// preserved comes back, and the body comes back byte for byte. +func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) { + local := issue.FromText(stored, "wire-sqlc-appclick") + req := ToRequest(local, roundTripOptions()) + + if req.Title == nil || *req.Title != local.Title { + t.Errorf("title = %v, want %q", req.Title, local.Title) + } + if req.Body == nil { + t.Fatal("the request carries no body — a create would file an empty issue") + } + if got := IDInBody(*req.Body); got != local.ID { + t.Errorf("the request body does not claim the slug: %q", got) + } + if got := StripIDMarker(*req.Body); got != strings.TrimSpace(local.Body) { + t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s", + got, strings.TrimSpace(local.Body)) + } + if want := []int64{11, 12}; req.Labels == nil || !reflect.DeepEqual(*req.Labels, want) { + t.Errorf("labels = %v, want %v", req.Labels, want) + } + if want := []string{"naudachu"}; req.Assignees == nil || !reflect.DeepEqual(*req.Assignees, want) { + t.Errorf("assignees = %v, want %v", req.Assignees, want) + } + if req.Milestone == nil || *req.Milestone != 5 { + t.Errorf("milestone = %v, want 5", req.Milestone) + } + if req.State == nil || *req.State != "open" { + t.Errorf("state = %v", req.State) + } + if req.Ref == nil || *req.Ref != "feat/wire-sqlc" { + t.Errorf("ref = %v — branch: is a sync field and must ride along", req.Ref) + } + + // What the tracker hands back is the body it was given, plus its own + // bookkeeping. + echo := &wire.Issue{ + Number: 42, + Title: *req.Title, + Body: *req.Body, + State: "open", + HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42", + UpdatedAt: "2026-08-09T18:24:01Z", + Ref: *req.Ref, + Comments: 3, + Labels: []wire.Label{{Name: "type/task"}, {Name: "tech/sql"}}, + Assignees: []wire.User{{Login: "naudachu"}}, + Milestone: &wire.Milestone{ID: 5, Title: "v0.2"}, + } + back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{ + IDForNumber: map[int]string{7: "migrate-schema"}, + Synced: "2026-08-09T18:40:00Z", + }) + if len(unresolved) != 0 { + t.Errorf("unresolved = %v, want none", unresolved) + } + + if back.Body != strings.TrimSpace(local.Body) { + t.Errorf("the body did not survive the trip:\n--- got ---\n%s\n--- want ---\n%s", + back.Body, strings.TrimSpace(local.Body)) + } + if strings.Contains(back.Body, "kettle:id") || strings.Contains(back.Body, "tea:id") { + t.Error("the marker reached the local copy — it is transport bookkeeping and belongs nowhere near disk") + } + for _, c := range []struct{ name, got, want string }{ + {"id", back.ID, local.ID}, + {"title", back.Title, local.Title}, + {"state", back.State, local.State}, + {"milestone", back.Milestone, local.Milestone}, + {"origin", back.Origin, local.Origin}, + {"gitea", back.Extra[GiteaKey], "claude-skills/tea#42"}, + {"branch", back.Extra[BranchKey], "feat/wire-sqlc"}, + {"synced", back.Extra[SyncedKey], "2026-08-09T18:40:00Z"}, + {"url", back.Extra[URLKey], "https://git.noodles.cam/claude-skills/tea/issues/42"}, + {"remote-updated", back.Extra[RemoteUpdatedKey], "2026-08-09T18:24:01Z"}, + {"comments", back.Extra[CommentsKey], "3"}, + } { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } + if !reflect.DeepEqual(back.Labels, local.Labels) { + t.Errorf("labels = %v, want %v", back.Labels, local.Labels) + } + if !reflect.DeepEqual(back.Assignees, local.Assignees) { + t.Errorf("assignees = %v, want %v", back.Assignees, local.Assignees) + } + // `depends:` is slugs; the `#7` the prose names is translated at this edge + // and the prose itself is left alone. + if !reflect.DeepEqual(back.Depends, local.Depends) { + t.Errorf("depends = %v, want %v", back.Depends, local.Depends) + } + if !strings.Contains(back.Body, "- #7 — нужна схема БД из этого issue") { + t.Error("the ## Depends on prose was rewritten; it is the author's text and passes through unchanged") + } + + // And the strongest form of "no churn": pushing what came back sends + // exactly what was sent the first time. + if again := ToRequest(back, roundTripOptions()); !reflect.DeepEqual(again, req) { + t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req) + } +} + +// The other shape an issue comes in: nothing scheduled, nobody assigned. +func TestNoMilestoneAndNoAssignees(t *testing.T) { + local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+ + "assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+ + "# A lone issue\n\n## Summary\nОдин.\n", "lone") + + req := ToRequest(local, RequestOptions{LabelIDs: map[string]int64{"type/task": 11}}) + if req.Assignees != nil { + t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", req.Assignees) + } + if req.Milestone != nil { + t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", req.Milestone) + } + + raw, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + body := string(raw) + for _, key := range []string{`"assignees"`, `"milestone"`, `"state"`, `"ref"`} { + if strings.Contains(body, key) { + t.Errorf("%s is in the request body; on a PATCH that overwrites what the tracker holds: %s", key, body) + } + } + // A resolved-but-empty label set is the opposite statement and must be sent. + if !strings.Contains(body, `"labels":[11]`) { + t.Errorf("labels missing from %s", body) + } + empty, err := json.Marshal(ToRequest(local, RequestOptions{LabelIDs: map[string]int64{}})) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(empty), `"labels":[]`) { + t.Errorf("a resolved label set that matched nothing must still be sent as []: %s", empty) + } + silent, err := json.Marshal(ToRequest(local, RequestOptions{})) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(silent), `"labels"`) { + t.Errorf("a caller that resolved no ids must not clear the tracker's labels: %s", silent) + } + + back, unresolved := FromPayload(&wire.Issue{ + Number: 9, + Title: "A lone issue", + Body: WithIDMarker("## Summary\nОдин.", "lone"), + State: "open", + HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9", + }, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"}) + + if len(unresolved) != 0 { + t.Errorf("unresolved = %v", unresolved) + } + if back.Milestone != "" || back.Assignees != nil || back.Labels != nil { + t.Errorf("empty came back as something: milestone=%q assignees=%v labels=%v", + back.Milestone, back.Assignees, back.Labels) + } + if _, ok := back.Extra[BranchKey]; ok { + t.Error("an absent ref must not write an empty branch: field") + } + if _, ok := back.Extra[CommentsKey]; ok { + t.Error("zero comments is not a fact worth a line in the file") + } + if !strings.Contains(back.Text(), "milestone: none") { + t.Error("an empty milestone must render back as none") + } +} + +// A dependency whose target is not in the store yet is reported, never invented: +// a made-up slug is an edge to a file that does not exist. +func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) { + body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n" + back, unresolved := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body}, + "here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}}) + + if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) { + t.Errorf("depends = %v, want %v", back.Depends, want) + } + if want := []int{8}; !reflect.DeepEqual(unresolved, want) { + t.Errorf("unresolved = %v, want %v", unresolved, want) + } + if !strings.Contains(back.Body, "- #8") { + t.Error("the body still names it, which is why dropping it from depends: loses nothing") + } +} + +func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) { + body := "## Summary\nx\n\n## Depends on\n- #7\n" + back, _ := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"}, + PayloadOptions{ + IDForNumber: map[int]string{7: "seven", 9: "nine"}, + ExtraNumbers: []int{7, 9}, + }) + if want := []string{"seven", "nine"}; !reflect.DeepEqual(back.Depends, want) { + t.Errorf("depends = %v, want %v", back.Depends, want) + } +} + +// The one exception to "a pull overwrites the body", and the narrowest one that +// works: a tick only ever travels one way, so the two sides are a set union. +func TestMergeCheckboxState(t *testing.T) { + cases := []struct { + name string + remote, local string + want string + wantUnchangedRef bool + }{ + { + name: "a local tick survives the overwrite", + remote: "- [ ] один\n- [ ] два\n", + local: "- [x] два\n", + want: "- [ ] один\n- [x] два\n", + }, + { + name: "rewrapping an item does not cost it its tick", + remote: "- [ ] очень длинный\n пункт\n", + local: "- [x] очень длинный пункт\n", + want: "- [x] очень длинный\n пункт\n", + }, + { + name: "the same text twice is the same item to whoever reads it", + remote: "- [ ] дубль\n- [ ] дубль\n", + local: "- [ ] дубль\n- [x] дубль\n", + want: "- [x] дубль\n- [x] дубль\n", + }, + { + name: "a first pull has nothing to merge", + remote: "- [ ] один\n", + local: "", + want: "- [ ] один\n", + }, + { + name: "unticking is not monotone, so it does not travel", + remote: "- [x] один\n", + local: "- [ ] один\n", + want: "- [x] один\n", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := MergeCheckboxState(c.remote, c.local); got != c.want { + t.Errorf("got:\n%q\nwant:\n%q", got, c.want) + } + }) + } +} + +// What `gitea:` holds is a key, and it round-trips through the one parser. +// Anything that is not a key reads as "not synced" — never as issue #0, and +// never as the issue -3 a bare strconv.Atoi would have handed back. +func TestRemoteKeyRoundTrip(t *testing.T) { + cases := []struct { + key string + repo string + number int + ok bool + }{ + {"claude-skills/tea#42", "claude-skills/tea", 42, true}, + {"o/r#1", "o/r", 1, true}, + // Never pushed, hand-edited, or written by a format that predates this + // one — all the same answer, and none of them is issue #0. `#42` is in + // the list because a handle without a repository addresses nothing. + {"", "", 0, false}, + {"claude-skills/tea", "", 0, false}, + {"#42", "", 0, false}, + {"o/r#", "", 0, false}, + {"o/r#-3", "", 0, false}, + {"o/r#4x", "", 0, false}, + } + for _, c := range cases { + got, ok := RemoteKeyOf(&issue.Issue{Extra: map[string]string{GiteaKey: c.key}}) + if got.Repo.String() != c.repo || got.Number != c.number || ok != c.ok { + t.Errorf("RemoteKeyOf(%q) = (%v, %v), want (%q, %d, %v)", + c.key, got, ok, c.repo, c.number, c.ok) + } + if c.ok && got.String() != c.key { + t.Errorf("the key formatted back as %q, want %q", got, c.key) + } + } +} + +func TestNumberOf(t *testing.T) { + synced := &issue.Issue{Extra: map[string]string{GiteaKey: "o/r#42"}} + if n, ok := NumberOf(synced); n != 42 || !ok { + t.Errorf("NumberOf = (%d, %v), want (42, true)", n, ok) + } + if _, ok := NumberOf(&issue.Issue{}); ok { + t.Error("an issue that has never been pushed has no number") + } +} + +func TestApplyRemoteStampsTheSyncFields(t *testing.T) { + local := &issue.Issue{ID: "x", Origin: issue.Local} + ApplyRemote(local, &wire.Issue{ + Number: 42, + HTMLURL: "https://git.noodles.cam/o/r/issues/42", + UpdatedAt: "2026-08-09T18:24:01Z", + }, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z") + + if local.IsLocal() { + t.Error("origin must move: the work exists somewhere else now") + } + if local.Extra[GiteaKey] != "o/r#42" || local.Extra[URLKey] == "" || + local.Extra[SyncedKey] != "2026-08-11T10:00:00Z" || + local.Extra[RemoteUpdatedKey] != "2026-08-09T18:24:01Z" { + t.Errorf("extra = %v", local.Extra) + } +} + +func TestRenderComments(t *testing.T) { + got := RenderComments([]wire.Comment{ + {ID: 1, User: wire.User{Login: "naudachu"}, CreatedAt: "2026-08-09T18:24:01Z", Body: " привет "}, + {ID: 2, User: wire.User{Login: "bot"}, CreatedAt: "", Body: ""}, + }) + want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" + + "## comment 2 — bot — \n\n(empty)\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} diff --git a/cli/internal/mapping/marker.go b/cli/internal/mapping/marker.go new file mode 100644 index 0000000..a2516e7 --- /dev/null +++ b/cli/internal/mapping/marker.go @@ -0,0 +1,110 @@ +package mapping + +import ( + "regexp" + "strings" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" +) + +// The id marker: the slug, kept tracker-side. +// +// Push deletes the local file once the tracker has confirmed the write, so the +// slug — the issue's ONLY identity in the domain — cannot live only on this +// machine any more. It rides up in the body as an HTML comment: +// +// +// +// Why the body and not a local number -> slug ledger: the ledger is a local +// file, and "the local copy is not the record" is the whole point of deleting +// one. A marker in the body survives a rename in the web UI, a lost ledger, a +// fresh clone, and a second machine — none of which the ledger does. Why an +// HTML comment: Gitea renders markdown, so it is invisible to a human reader, +// and it comes back verbatim on every API read. +// +// WHERE: the first line of the tracker-side body, followed by one blank line. +// First because it is the one position that does not depend on what sections +// the issue happens to have, and because a human who does look at the raw +// markdown finds it before the prose rather than buried in it. +// +// WHAT THE LOCAL FILE SEES: nothing. FromPayload strips every marker before the +// body reaches the store, so `.kettle/issues/.md` holds exactly what the +// author wrote — checkbox line numbers, `kettle check`, and diffs are all +// unaffected, and the slug is already the file's name, so a copy of it in the +// body would be duplicated state. +// +// WHY IT CANNOT ACCUMULATE: the two operations are strip-all and +// strip-all-then-prepend-one. WithIDMarker never appends to what is there, and +// StripIDMarker removes EVERY marker line, not the first. So a body that +// somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on +// the next pull and goes back up with exactly one. There is no code path that +// adds a marker to a body that has not just been stripped. +// +// WHY TWO SPELLINGS ARE READ AND ONE IS WRITTEN: this tool was called `tea` +// and wrote ``. Issues pushed under that name are sitting in +// the tracker right now, and their local files are gone — the marker is the +// only copy of their slug there is. A rename that stopped reading the old +// spelling would orphan every one of them: the pull would fall back to the +// title, allocate a fresh slug, and every `depends:` pointing at the old one +// would dangle. So the writer moved and the reader did not. +var markerRe = regexp.MustCompile( + `^[ \t]*[ \t]*$`) + +// IDMarker is the marker line for a slug. One place formats it, one regex +// reads it — and what that regex accepts is deliberately wider than this. +func IDMarker(id string) string { return "" } + +// IDInBody is the slug a tracker-side body claims, or "" when it claims none. +// +// The FIRST valid marker wins; a second one is ignored here and removed by +// StripIDMarker on the way in. The captured text must be a slug by the domain's +// own rule — a marker holding anything else is not a slug and is treated as if +// it were not there, so a mangled comment falls back to the title instead of +// naming a file after garbage. +func IDInBody(body string) string { + for _, line := range strings.Split(body, "\n") { + if m := markerRe.FindStringSubmatch(strings.TrimSuffix(line, "\r")); m != nil { + if issue.IsSlug(m[1]) { + return m[1] + } + } + } + return "" +} + +// StripIDMarker is body with every marker line removed, in either spelling. +// Idempotent. +// +// A body that carries no marker is returned byte for byte — the common case (an +// issue filed in the web UI) costs nothing and is not reformatted. When a marker +// is removed from the top, the blank line it was written with goes with it, so +// the round trip is exact: StripIDMarker(WithIDMarker(b, id)) == b. +func StripIDMarker(body string) string { + lines := strings.Split(body, "\n") + found := false + for _, line := range lines { + if markerRe.MatchString(strings.TrimSuffix(line, "\r")) { + found = true + break + } + } + if !found { + return body + } + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if !markerRe.MatchString(strings.TrimSuffix(line, "\r")) { + kept = append(kept, line) + } + } + return strings.TrimLeft(strings.Join(kept, "\n"), "\n") +} + +// WithIDMarker is body with exactly one marker, as its first line. +// +// Strip-then-prepend, always — that is the guarantee that a body can never end +// up with two, however many it arrived with, and it is what quietly rewrites a +// `tea:id` marker into the current spelling the next time the issue is pushed. +func WithIDMarker(body, id string) string { + return IDMarker(id) + "\n\n" + StripIDMarker(body) +} diff --git a/cli/internal/mapping/marker_test.go b/cli/internal/mapping/marker_test.go new file mode 100644 index 0000000..bdba674 --- /dev/null +++ b/cli/internal/mapping/marker_test.go @@ -0,0 +1,104 @@ +package mapping + +import ( + "strings" + "testing" + + "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" +) + +func TestIDInBodyReadsBothSpellings(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {"the spelling this tool writes", "\n\n## Summary\nx", "wire-sqlc"}, + // The whole reason the reader is wider than the writer. + {"the spelling already in the tracker", "\n\n## Summary\nx", "wire-sqlc"}, + {"indented and loosely spaced", " \n", "wire-sqlc"}, + {"no marker at all", "## Summary\nx", ""}, + // A mangled comment falls back to the title rather than naming a file + // after garbage. + {"not a slug", "\n", ""}, + {"not on a line of its own", "text \n", ""}, + {"the first valid marker wins", "\n\n", "first-one"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := IDInBody(c.body); got != c.want { + t.Errorf("IDInBody = %q, want %q", got, c.want) + } + }) + } +} + +// An issue pushed under the old name is sitting in the tracker with its local +// file long since deleted — the marker is the only copy of its slug there is. +// It has to keep resolving, and it has to come back up in the new spelling. +func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) { + const inTracker = "\n\n## Summary\nПроводка sqlc.\n" + + id := IDInBody(inTracker) + if id != "wire-sqlc-appclick" { + t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id) + } + + iss, _ := FromPayload(&wire.Issue{Number: 42, Title: "Wire sqlc", Body: inTracker}, + id, tea, PayloadOptions{}) + if strings.Contains(iss.Body, "tea:id") { + t.Errorf("the old marker reached the local copy: %q", iss.Body) + } + if iss.Body != "## Summary\nПроводка sqlc." { + t.Errorf("body = %q", iss.Body) + } + + // And the next push rewrites it into the current spelling, without ever + // having two. + up := *ToRequest(iss, RequestOptions{}).Body + if !strings.HasPrefix(up, "\n\n") { + t.Errorf("the marker was not rewritten: %q", up) + } + if strings.Contains(up, "tea:id") { + t.Errorf("both spellings went up: %q", up) + } + if n := strings.Count(up, ":id "); n != 1 { + t.Errorf("%d markers in the body, want 1", n) + } +} + +func TestMarkersCannotAccumulate(t *testing.T) { + body := "## Summary\nx" + // Whatever it arrived with — one, the other, several — it goes up with one. + messy := "\n\n\n\n" + body + got := WithIDMarker(messy, "real-one") + + if want := IDMarker("real-one") + "\n\n" + body; got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } + if got := WithIDMarker(got, "real-one"); strings.Count(got, "