feat: add the kettle CLI, replacing the plugin's Python scripts
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
var numberRe = regexp.MustCompile(`^\d+$`)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "ac",
|
||||
Group: GroupIssue,
|
||||
Args: "<id>",
|
||||
Short: "list and tick an issue's checkboxes",
|
||||
Long: `A checkbox is the one part of a body that is *state* and not prose. Everything
|
||||
else is written once; boxes get ticked as the work goes, and the only other ways
|
||||
to tick one are a human with an editor or a model rewriting the whole body — the
|
||||
second worse than the first, because the rewrite re-flows the text and the
|
||||
issue's diff swells around a change of one character. This changes that one
|
||||
character and nothing else.
|
||||
|
||||
Named after ` + "`## Acceptance criteria`" + `, where most boxes live, but every checkbox in
|
||||
the body is listed and tickable: a type/feature keeps its children under
|
||||
` + "`## Issues`" + `, and binding this to one heading would silently lose half of them.
|
||||
|
||||
A substring picks an item only when it picks exactly one. Two matches is an
|
||||
error listing both — a coin flip would tick the wrong box and look like it
|
||||
worked.
|
||||
|
||||
Delivering the changed body to a tracker is not part of this; that is
|
||||
` + "`kettle push --update`" + `.`,
|
||||
Examples: []Example{
|
||||
{"kettle ac wire-sqlc-appclick", "numbered list with state"},
|
||||
{"kettle ac wire-sqlc-appclick --check 3", "tick by number"},
|
||||
{"kettle ac wire-sqlc-appclick --check регресс", "tick by substring"},
|
||||
{"kettle ac wire-sqlc-appclick --uncheck 3", "untick it again"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
check := fs.String("check", "", "tick one item: number or substring")
|
||||
uncheck := fs.String("uncheck", "", "untick one item: number or substring")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return Fail("give exactly one issue id")
|
||||
}
|
||||
checking, unchecking := wasSet(fs, "check"), wasSet(fs, "uncheck")
|
||||
if checking && unchecking {
|
||||
return Fail("--check and --uncheck are mutually exclusive")
|
||||
}
|
||||
id := args[0]
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := issue.PathOf(root, id)
|
||||
// Raw bytes in and raw bytes out: byte-for-byte means the line
|
||||
// endings too. Reading a CRLF file with translation and writing
|
||||
// it back would rewrite every line while claiming to have
|
||||
// changed one character.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Fail("no issue %q in %s", id, root)
|
||||
}
|
||||
text := string(raw)
|
||||
|
||||
// The whole file, not just the body: line numbers then point at
|
||||
// the file, and the metadata block is rewritten by nobody.
|
||||
// Round-tripping through the parser would re-render metadata and
|
||||
// re-strip the body, which is exactly the churn this avoids.
|
||||
items := issue.Checkboxes(text)
|
||||
needle, checked := *check, true
|
||||
if unchecking {
|
||||
needle, checked = *uncheck, false
|
||||
}
|
||||
selecting := checking || unchecking
|
||||
|
||||
if len(items) == 0 {
|
||||
if selecting {
|
||||
return Fail("%s has no checkboxes", id)
|
||||
}
|
||||
fmt.Printf("%s — no checkboxes\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !selecting {
|
||||
done, total := issue.CheckboxProgress(text)
|
||||
fmt.Printf("%s — %d/%d %s\n", id, done, total, path)
|
||||
fmt.Println(strings.Join(listing(items), "\n"))
|
||||
return nil
|
||||
}
|
||||
|
||||
item, err := selectItem(items, needle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := issue.SetCheckbox(text, item.Line, checked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated == text {
|
||||
fmt.Printf("unchanged %2d %s %s\n", item.Index, box(item.Checked), item.Text)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := issue.BuildIndex(root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verb := "checked"
|
||||
if !checked {
|
||||
verb = "unchecked"
|
||||
}
|
||||
done, total := issue.CheckboxProgress(updated)
|
||||
fmt.Printf("%s %2d %s %s\n", verb, item.Index, box(checked), item.Text)
|
||||
fmt.Printf("%s — %d/%d %s:%d\n", id, done, total, path, item.Line)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func box(checked bool) string {
|
||||
if checked {
|
||||
return "[x]"
|
||||
}
|
||||
return "[ ]"
|
||||
}
|
||||
|
||||
// listing is the numbered list, grouped by the heading each item sits under.
|
||||
func listing(items []issue.Checkbox) []string {
|
||||
var out []string
|
||||
section := "\x00" // no heading can equal this, so the first item opens a group
|
||||
for _, c := range items {
|
||||
if c.Section != section {
|
||||
section = c.Section
|
||||
head := section
|
||||
if head == "" {
|
||||
head = "(above the first heading)"
|
||||
}
|
||||
out = append(out, "", head)
|
||||
}
|
||||
out = append(out, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// selectItem resolves a --check/--uncheck argument to exactly one item.
|
||||
func selectItem(items []issue.Checkbox, needle string) (issue.Checkbox, error) {
|
||||
needle = strings.TrimSpace(needle)
|
||||
if needle == "" {
|
||||
return issue.Checkbox{}, Fail("empty selector — give an item number or a substring")
|
||||
}
|
||||
if numberRe.MatchString(needle) {
|
||||
n, _ := strconv.Atoi(needle)
|
||||
if n < 1 || n > len(items) {
|
||||
return issue.Checkbox{}, Fail("no item %d — the issue has %d", n, len(items))
|
||||
}
|
||||
return items[n-1], nil
|
||||
}
|
||||
var hits []issue.Checkbox
|
||||
for _, c := range items {
|
||||
if strings.Contains(strings.ToLower(c.Text), strings.ToLower(needle)) {
|
||||
hits = append(hits, c)
|
||||
}
|
||||
}
|
||||
switch len(hits) {
|
||||
case 0:
|
||||
return issue.Checkbox{}, Fail("nothing matches %q", needle)
|
||||
case 1:
|
||||
return hits[0], nil
|
||||
}
|
||||
lines := []string{fmt.Sprintf("%q matches %d items — narrow it down, or use a number:", needle, len(hits))}
|
||||
for _, c := range hits {
|
||||
lines = append(lines, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
|
||||
}
|
||||
return issue.Checkbox{}, Fail("%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "auth",
|
||||
Group: GroupProject,
|
||||
Args: "list | add | remove <name>",
|
||||
Short: "manage the tokens this machine holds",
|
||||
Long: `Credentials live in one file per machine, outside every working tree, mode
|
||||
0600. A project pins a login by NAME; the name is worth nothing on its own,
|
||||
which is what makes it safe to keep in a file inside the repository.
|
||||
|
||||
The token is read from standard input unless --token is given, because an
|
||||
argument is in the shell history the moment it is typed:
|
||||
|
||||
kettle auth add --name noodles --url https://git.example.com < token.txt
|
||||
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
||||
|
||||
` + "`list`" + ` never prints a token. There is no flag to make it.`,
|
||||
Examples: []Example{
|
||||
{"kettle auth list", "what this machine holds"},
|
||||
{"pass show gitea | kettle auth add --name noodles --url https://git.example.com", "add one, token on stdin"},
|
||||
{"kettle auth remove noodles", "forget it"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
name := fs.String("name", "", "login name (add)")
|
||||
url := fs.String("url", "", "instance URL, e.g. https://git.example.com (add)")
|
||||
user := fs.String("user", "", "account this token belongs to; documentation only (add)")
|
||||
token := fs.String("token", "", "token, if you would rather not use stdin (add)")
|
||||
|
||||
return func(args []string) error {
|
||||
verb := "list"
|
||||
if len(args) > 0 {
|
||||
verb = args[0]
|
||||
}
|
||||
logins, err := config.LoadLogins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch verb {
|
||||
case "list":
|
||||
if len(logins.Logins) == 0 {
|
||||
fmt.Printf("no logins in %s\n", config.LoginsPath())
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s\n\n", config.LoginsPath())
|
||||
for _, l := range logins.Logins {
|
||||
who := l.User
|
||||
if who == "" {
|
||||
who = "—"
|
||||
}
|
||||
fmt.Printf(" %-16s %-40s %s\n", l.Name, l.URL, who)
|
||||
}
|
||||
return nil
|
||||
|
||||
case "add":
|
||||
if *name == "" || *url == "" {
|
||||
return Fail("--name and --url are both required")
|
||||
}
|
||||
secret := *token
|
||||
if secret == "" {
|
||||
if secret, err = readToken(os.Stdin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
return Fail("no token — pipe one in, or pass --token")
|
||||
}
|
||||
entry := config.Login{
|
||||
Name: *name,
|
||||
URL: strings.TrimRight(*url, "/"),
|
||||
User: *user,
|
||||
Token: secret,
|
||||
}
|
||||
if existing := logins.Find(*name); existing != nil {
|
||||
*existing = entry
|
||||
} else {
|
||||
logins.Logins = append(logins.Logins, entry)
|
||||
}
|
||||
if err := config.SaveLogins(logins); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s -> %s %s\n", entry.Name, entry.URL, config.LoginsPath())
|
||||
return nil
|
||||
|
||||
case "remove":
|
||||
if len(args) != 2 {
|
||||
return Fail("give exactly one login name to remove")
|
||||
}
|
||||
target := args[1]
|
||||
kept := logins.Logins[:0]
|
||||
found := false
|
||||
for _, l := range logins.Logins {
|
||||
if l.Name == target {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if !found {
|
||||
return Fail("no login %q in %s", target, config.LoginsPath())
|
||||
}
|
||||
logins.Logins = kept
|
||||
if err := config.SaveLogins(logins); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s\n", target)
|
||||
return nil
|
||||
}
|
||||
return Fail("unknown subcommand %q — list, add, or remove", verb)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// readToken takes the first non-empty line of r, trimmed.
|
||||
//
|
||||
// The first line, not the whole stream: a token piped from a password manager
|
||||
// often arrives with a trailing newline and sometimes with notes underneath it.
|
||||
func readToken(r io.Reader) (string, error) {
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
if line := strings.TrimSpace(sc.Text()); line != "" {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "check",
|
||||
Group: GroupIssue,
|
||||
Args: "[<id>…]",
|
||||
Short: "validate issues against the canonical format",
|
||||
Long: `The same check the sync layer runs before it pushes anything, available on its
|
||||
own so a local-only issue can be held to the format without a tracker being
|
||||
involved.
|
||||
|
||||
Errors mean malformed; warnings mean it deviates from its type's template or its
|
||||
graph looks suspect. An unticked checkbox is neither: work not done yet is the
|
||||
normal state of a perfectly well-formed issue.
|
||||
|
||||
Exit status is 1 when anything has errors, which is what makes this usable in a
|
||||
hook or a CI step.`,
|
||||
Examples: []Example{
|
||||
{"kettle check", "every issue in the store"},
|
||||
{"kettle check wire-sqlc-appclick", "one issue"},
|
||||
{"kettle check --quiet", "exit status only"},
|
||||
{"kettle check --strict", "treat warnings as errors"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
quiet := fs.Bool("quiet", false, "exit status only, print nothing")
|
||||
strict := fs.Bool("strict", false, "treat warnings as errors")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issue.StoreError(root); err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ids := args
|
||||
if len(ids) == 0 {
|
||||
for id := range issues {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for id := range issues {
|
||||
known[id] = true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !known[id] {
|
||||
return Fail("no issue %q in %s", id, root)
|
||||
}
|
||||
}
|
||||
|
||||
bad := 0
|
||||
for _, id := range ids {
|
||||
errs, warns := issue.Validate(issues[id], known)
|
||||
if *strict {
|
||||
errs, warns = append(errs, warns...), nil
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
bad++
|
||||
}
|
||||
if *quiet {
|
||||
continue
|
||||
}
|
||||
if len(errs) == 0 && len(warns) == 0 {
|
||||
fmt.Printf("ok %s\n", id)
|
||||
continue
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Printf("ERROR %s: %s\n", id, e)
|
||||
}
|
||||
for _, w := range warns {
|
||||
fmt.Printf("warn %s: %s\n", id, w)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range issue.FindCycles(issue.Graph(issues)) {
|
||||
bad++
|
||||
if !*quiet {
|
||||
fmt.Printf("ERROR cycle: %s\n", strings.Join(c, " -> "))
|
||||
}
|
||||
}
|
||||
|
||||
if !*quiet {
|
||||
fmt.Printf("%d issue(s) checked, %d with errors\n", len(ids), bad)
|
||||
}
|
||||
if bad > 0 {
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package cmd_test
|
||||
|
||||
// The CLI is tested the way the Python suite it replaces was: the binary is
|
||||
// built once and run as a subprocess against a throwaway project somewhere
|
||||
// else entirely.
|
||||
//
|
||||
// That separation IS the contract. A tool is installed in one place and used on
|
||||
// projects in another, and the bug this discipline exists to catch — a store
|
||||
// resolved from the executable's own directory rather than from the tree it was
|
||||
// pointed at — is invisible to any test that runs the code in the directory it
|
||||
// lives in.
|
||||
//
|
||||
// Every fixture also strips CLAUDE_PROJECT_DIR unless the test is about it: it
|
||||
// is the first anchor of the walk, so the harness's own value would point every
|
||||
// fixture at this repository.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var kettle string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "kettle-bin")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
kettle = filepath.Join(dir, "kettle")
|
||||
build := exec.Command("go", "build", "-o", kettle, "../../cmd/kettle")
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
panic("building kettle: " + err.Error() + "\n" + string(out))
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type result struct {
|
||||
stdout, stderr string
|
||||
code int
|
||||
}
|
||||
|
||||
func (r result) out() string { return r.stdout + r.stderr }
|
||||
|
||||
// run invokes the binary in dir with a clean environment.
|
||||
func run(t *testing.T, dir string, args ...string) result {
|
||||
t.Helper()
|
||||
return runWith(t, dir, nil, "", args...)
|
||||
}
|
||||
|
||||
// runWith is run plus extra environment and standard input.
|
||||
func runWith(t *testing.T, dir string, env []string, stdin string, args ...string) result {
|
||||
t.Helper()
|
||||
cmd := exec.Command(kettle, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(append(os.Environ(), "CLAUDE_PROJECT_DIR="), env...)
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
code := 0
|
||||
var ee *exec.ExitError
|
||||
if err != nil {
|
||||
if !asExitError(err, &ee) {
|
||||
t.Fatalf("running kettle %v: %v", args, err)
|
||||
}
|
||||
code = ee.ExitCode()
|
||||
}
|
||||
return result{stdout.String(), stderr.String(), code}
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, dir string, args ...string) result {
|
||||
t.Helper()
|
||||
r := run(t, dir, args...)
|
||||
if r.code != 0 {
|
||||
t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// newProject makes an initialized project in a temp directory and returns it.
|
||||
func newProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "init")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestInitIsIdempotentAndGitignoresTheStore(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
|
||||
for _, d := range []string{".kettle/issues", ".kettle/payload"} {
|
||||
if fi, err := os.Stat(filepath.Join(dir, d)); err != nil || !fi.IsDir() {
|
||||
t.Errorf("%s was not created", d)
|
||||
}
|
||||
}
|
||||
// An `origin: local` issue is the only copy of that work, and what goes in
|
||||
// a shared history is the operator's call, not this command's.
|
||||
ignore, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
|
||||
if err != nil || !strings.Contains(string(ignore), ".kettle/") {
|
||||
t.Errorf(".kettle/ was not gitignored: %q", ignore)
|
||||
}
|
||||
|
||||
again := mustRun(t, dir, "init")
|
||||
if !strings.Contains(again.stdout, "already initialized") {
|
||||
t.Errorf("a second init should be a no-op, got:\n%s", again.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMarkerIsReportedNotGuessed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := run(t, dir, "check")
|
||||
|
||||
if r.code == 0 {
|
||||
t.Fatal("a directory that is not a project must not read as an empty store")
|
||||
}
|
||||
// The operator is owed the directories the search began from — that is how
|
||||
// they see whether it began where they meant it to.
|
||||
if !strings.Contains(r.stderr, "no .kettle/ found") || !strings.Contains(r.stderr, dir) {
|
||||
t.Errorf("the failure must name what it searched:\n%s", r.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGoldenPath(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
|
||||
const id = "wire-sqlc-into-the-appclick-layer"
|
||||
|
||||
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "ok "+id) {
|
||||
t.Errorf("a fresh issue from its own template must validate:\n%s", r.out())
|
||||
}
|
||||
|
||||
// Progress is counted off the body every time, never stored.
|
||||
mustRun(t, dir, "ac", id, "--check", "1")
|
||||
index, err := os.ReadFile(filepath.Join(dir, ".kettle", "issues", "INDEX.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(index), "| 1/2 |") {
|
||||
t.Errorf("the index did not pick up the ticked box:\n%s", index)
|
||||
}
|
||||
|
||||
if r := mustRun(t, dir, "tree"); !strings.Contains(r.stdout, id) {
|
||||
t.Errorf("tree did not draw the issue:\n%s", r.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickingABoxChangesOneByte(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Tick one box")
|
||||
path := filepath.Join(dir, ".kettle", "issues", "tick-one-box.md")
|
||||
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "ac", "tick-one-box", "--check", "1")
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(before) != len(after) {
|
||||
t.Fatalf("length changed: %d -> %d", len(before), len(after))
|
||||
}
|
||||
diff := 0
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
diff++
|
||||
}
|
||||
}
|
||||
if diff != 1 {
|
||||
t.Errorf("%d bytes changed, want 1 — a tick must not re-render the file", diff)
|
||||
}
|
||||
|
||||
// And back again, byte for byte: the metadata block is rewritten by nobody.
|
||||
mustRun(t, dir, "ac", "tick-one-box", "--uncheck", "1")
|
||||
back, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(back) != string(before) {
|
||||
t.Error("unticking did not restore the file byte for byte")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsWorkAfterPositionalArguments(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Order of arguments")
|
||||
|
||||
// `kettle ac <id> --check 1` is how everybody types it. A flag silently
|
||||
// read as a positional would tick nothing and report success.
|
||||
r := mustRun(t, dir, "ac", "order-of-arguments", "--check", "1")
|
||||
if !strings.Contains(r.stdout, "checked") {
|
||||
t.Errorf("the flag after the id was ignored:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStoreResolvesFromAnywhereInsideTheProject(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Seen from below")
|
||||
|
||||
deep := filepath.Join(dir, "internal", "adapters")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := mustRun(t, deep, "check")
|
||||
if !strings.Contains(r.stdout, "seen-from-below") {
|
||||
t.Errorf("a subdirectory saw a different store:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestADifferentProjectAnswersWithItsOwnStore(t *testing.T) {
|
||||
a, b := newProject(t), newProject(t)
|
||||
mustRun(t, a, "new", "--type", "task", "--title", "Belongs to A")
|
||||
mustRun(t, b, "new", "--type", "task", "--title", "Belongs to B")
|
||||
|
||||
r := mustRun(t, b, "check")
|
||||
if strings.Contains(r.stdout, "belongs-to-a") {
|
||||
t.Errorf("project B saw project A's issues:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestALocalIssueIsNeverEvictedEvenWhenNamed(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Only copy there is")
|
||||
path := filepath.Join(dir, ".kettle", "issues", "only-copy-there-is.md")
|
||||
closeIssue(t, path)
|
||||
|
||||
r := mustRun(t, dir, "evict", "only-copy-there-is")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal("a closed origin: local issue was deleted — that file IS the work")
|
||||
}
|
||||
if !strings.Contains(r.stdout, "kept") {
|
||||
t.Errorf("keeping it must be said out loud:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClosedTrackedIssueIsEvictedWithItsSidecars(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Done and elsewhere")
|
||||
store := filepath.Join(dir, ".kettle", "issues")
|
||||
path := filepath.Join(store, "done-and-elsewhere.md")
|
||||
closeIssue(t, path)
|
||||
setField(t, path, "origin", "gitea")
|
||||
|
||||
sidecar := filepath.Join(store, "done-and-elsewhere.comments.md")
|
||||
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A dry run touches nothing, and says so.
|
||||
dry := mustRun(t, dir, "evict", "--dry-run")
|
||||
if !strings.Contains(dry.stdout, "would evict") {
|
||||
t.Errorf("dry run said nothing:\n%s", dry.out())
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal("a dry run deleted the issue")
|
||||
}
|
||||
|
||||
mustRun(t, dir, "evict")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Error("the issue survived eviction")
|
||||
}
|
||||
// The domain does not need to know what a comment thread is to know a file
|
||||
// named after this issue goes when it goes.
|
||||
if _, err := os.Stat(sidecar); err == nil {
|
||||
t.Error("the sidecar was left behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExitsNonZeroOnAMalformedIssue(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Loses its type")
|
||||
path := filepath.Join(dir, ".kettle", "issues", "loses-its-type.md")
|
||||
setField(t, path, "labels", "[]")
|
||||
|
||||
r := run(t, dir, "check")
|
||||
if r.code != 1 {
|
||||
t.Errorf("exit = %d, want 1 — this is what makes check usable in a hook", r.code)
|
||||
}
|
||||
if !strings.Contains(r.stdout, "need exactly one type/* label") {
|
||||
t.Errorf("the finding was not reported:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRefusesToOverwriteAnExistingIssue(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
|
||||
// Without an explicit id the slug is allocated around the collision…
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "same-title-twice-2.md")); err != nil {
|
||||
t.Error("the second issue did not get its own slug")
|
||||
}
|
||||
// …but an id typed by hand is taken literally, and taken means taken.
|
||||
r := run(t, dir, "new", "--type", "task", "--title", "Third", "--id", "same-title-twice")
|
||||
if r.code == 0 || !strings.Contains(r.stderr, "already exists") {
|
||||
t.Errorf("an explicit id must not overwrite:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// An older layout is migrated in, and it is a MOVE: a store left behind at the
|
||||
// old path is a store somebody will edit by accident months later.
|
||||
func TestInitMigratesAnOlderStore(t *testing.T) {
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := filepath.Join(dir, ".tea", "issues")
|
||||
if err := os.MkdirAll(old, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const body = "---\nid: from-the-old-store\nstate: open\nlabels: [type/task]\norigin: local\n---\n# From the old store\n\n## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n"
|
||||
if err := os.WriteFile(filepath.Join(old, "from-the-old-store.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "init")
|
||||
if !strings.Contains(r.stdout, "moved 1 file(s)") {
|
||||
t.Errorf("the migration said nothing:\n%s", r.stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "from-the-old-store.md")); err != nil {
|
||||
t.Fatal("the issue did not arrive in the new store")
|
||||
}
|
||||
if _, err := os.Stat(old); err == nil {
|
||||
t.Error("the old store is still there — two stores is what the marker exists to prevent")
|
||||
}
|
||||
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "from-the-old-store") {
|
||||
t.Errorf("the migrated issue is not readable:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// A migration never picks a winner. Two files of the same name are two versions
|
||||
// of one issue, and choosing quietly is how the wrong one survives.
|
||||
func TestInitRefusesToResolveAMigrationClash(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
old := filepath.Join(dir, ".tea", "issues")
|
||||
if err := os.MkdirAll(old, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Both sides have this")
|
||||
if err := os.WriteFile(filepath.Join(old, "both-sides-have-this.md"), []byte("older\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := run(t, dir, "init")
|
||||
if r.code == 0 {
|
||||
t.Fatal("a clash must stop the run")
|
||||
}
|
||||
if !strings.Contains(r.stderr, "both hold") || !strings.Contains(r.stderr, "nothing was changed") {
|
||||
t.Errorf("the clash was not explained:\n%s", r.stderr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(old, "both-sides-have-this.md")); err != nil {
|
||||
t.Error("the older file was moved anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWritesTheConfigAndKeepsWhatItWasNotGiven(t *testing.T) {
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "init", "--login", "noodles", "--repo", "claude-skills/marketplace")
|
||||
|
||||
cfg := filepath.Join(dir, ".kettle", "config.yaml")
|
||||
raw, err := os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "login: noodles") ||
|
||||
!strings.Contains(string(raw), "repo: claude-skills/marketplace") {
|
||||
t.Fatalf("config did not record what it was given:\n%s", raw)
|
||||
}
|
||||
|
||||
// Re-running init to change one setting must not drop the other.
|
||||
mustRun(t, dir, "init", "--repo", "claude-skills/other")
|
||||
raw, err = os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "login: noodles") {
|
||||
t.Errorf("the pinned login was dropped by an unrelated init:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "repo: claude-skills/other") {
|
||||
t.Errorf("the repository was not updated:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRefusesAMalformedRepo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := run(t, dir, "init", "--repo", "marketplace")
|
||||
if r.code == 0 || !strings.Contains(r.stderr, "owner/name") {
|
||||
t.Errorf("a repo without an owner must be rejected before anything is written:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
|
||||
t.Error("the marker was created despite the bad argument")
|
||||
}
|
||||
}
|
||||
|
||||
// The project pins a login by NAME. The credential lives in one file per
|
||||
// machine, outside every working tree — a token in a repository ends up in a
|
||||
// commit, and a secret that has been pushed has to be rotated.
|
||||
func TestTokensNeverLandInTheProject(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
home := t.TempDir()
|
||||
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||
|
||||
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add",
|
||||
"--name", "noodles", "--url", "https://git.example.com/")
|
||||
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
|
||||
|
||||
logins, err := os.ReadFile(filepath.Join(home, "logins.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal("the token file was not written where it was told to go")
|
||||
}
|
||||
if !strings.Contains(string(logins), "s3cr3t-token") {
|
||||
t.Errorf("the token was not stored:\n%s", logins)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(home, "logins.yaml")); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the token file must be 0600, got %v", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
cfg, err := os.ReadFile(filepath.Join(dir, ".kettle", "config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(cfg), "s3cr3t-token") {
|
||||
t.Fatal("the token was written into the project — that file ends up in a commit")
|
||||
}
|
||||
|
||||
// And nothing prints it back, either.
|
||||
shown := runWith(t, dir, env, "", "config")
|
||||
if strings.Contains(shown.out(), "s3cr3t-token") {
|
||||
t.Errorf("`kettle config` printed the token:\n%s", shown.out())
|
||||
}
|
||||
if !strings.Contains(shown.stdout, "https://git.example.com") {
|
||||
t.Errorf("the resolved URL was not shown:\n%s", shown.out())
|
||||
}
|
||||
if !strings.Contains(shown.stdout, "token (set)") {
|
||||
t.Errorf("whether a token was found must still be visible:\n%s", shown.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListNeverPrintsATokenAndRemoveForgetsIt(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
home := t.TempDir()
|
||||
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||
|
||||
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles", "--url", "https://git.example.com")
|
||||
listed := runWith(t, dir, env, "", "auth", "list")
|
||||
if strings.Contains(listed.out(), "s3cr3t-token") {
|
||||
t.Errorf("`auth list` printed a token:\n%s", listed.out())
|
||||
}
|
||||
if !strings.Contains(listed.stdout, "noodles") {
|
||||
t.Errorf("`auth list` did not list the login:\n%s", listed.out())
|
||||
}
|
||||
|
||||
runWith(t, dir, env, "", "auth", "remove", "noodles")
|
||||
after := runWith(t, dir, env, "", "auth", "list")
|
||||
if strings.Contains(after.stdout, "noodles") {
|
||||
t.Errorf("the login survived removal:\n%s", after.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// A pinned login that is not on this machine is a fixable mistake, and the
|
||||
// message has to say which file was read and what it holds.
|
||||
func TestAMissingLoginIsExplained(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
home := t.TempDir()
|
||||
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||
mustRun(t, dir, "init", "--login", "absent", "--repo", "owner/name")
|
||||
|
||||
r := runWith(t, dir, env, "", "config")
|
||||
if r.code == 0 {
|
||||
t.Fatal("a login that does not exist must not resolve")
|
||||
}
|
||||
if !strings.Contains(r.stderr, `no login "absent"`) || !strings.Contains(r.stderr, "kettle auth add") {
|
||||
t.Errorf("the failure must name the file and the fix:\n%s", r.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func closeIssue(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
setField(t, path, "state", "closed")
|
||||
}
|
||||
|
||||
func setField(t *testing.T, path, key, value string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, key+": ") {
|
||||
lines[i] = key + ": " + value
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func asExitError(err error, target **exec.ExitError) bool {
|
||||
ee, ok := err.(*exec.ExitError)
|
||||
if ok {
|
||||
*target = ee
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "close",
|
||||
Group: GroupSync,
|
||||
Args: "<id|number> [<id|number>…]",
|
||||
Short: "close or reopen issues in the tracker, and on disk with them",
|
||||
Long: `STATE ONLY. This sends ` + "`{\"state\": …}`" + ` and nothing else: no title, no body, no
|
||||
labels, no milestone. Editing an issue is ` + "`kettle pull`" + ` -> edit ->
|
||||
` + "`kettle push --update`" + `; closing it is not an edit.
|
||||
|
||||
EXPLICIT IDS ONLY. No --milestone, no --label, no "close everything that looks
|
||||
done". Which issues are finished is a judgement about content; this carries that
|
||||
judgement out, one named id at a time. Nothing here deletes an issue either —
|
||||
the tracker can, and it is not an operation of this workflow.
|
||||
|
||||
WHAT MAY BE NAMED: a local slug, or a tracker key (42, #42, owner/repo#42, an
|
||||
issue URL). Both, and for the same reason: a push deletes the local file, so
|
||||
most issues in the tracker have no slug on disk to name them by. A slug is
|
||||
resolved through the file's ` + "`gitea:`" + ` handle when the file is there, and through
|
||||
the ledger (` + "`.remote.json`" + `) when push has already dropped it. A bare number is
|
||||
this project's repository; a qualified key names its own, so a foreign #42 can
|
||||
never be closed against the repository that happens to be configured here.
|
||||
|
||||
An ` + "`origin: local`" + ` issue cannot be closed. It is not in the tracker, so there is
|
||||
no state there to change, and the run stops naming the id rather than quietly
|
||||
editing one field of a local file. Push it first, or delete it.
|
||||
|
||||
THE LOCAL FILE IS WRITTEN ONLY AFTER THE TRACKER CONFIRMS: the answer has to be
|
||||
the very issue that was patched, in the state that was asked for. Anything else
|
||||
and the file is left exactly as it was. An issue whose local copy is gone
|
||||
(pushed and dropped) is closed in the tracker and nothing is written; the state
|
||||
comes down with the next pull.
|
||||
|
||||
A tracker that refuses to close an issue its own dependency graph still blocks
|
||||
says so in the answer, and the run stops with its words: close the blockers
|
||||
first, or unlink them.`,
|
||||
Examples: []Example{
|
||||
{"kettle close wire-sqlc-appclick", "one issue, by slug"},
|
||||
{"kettle close wire-sqlc-appclick 42 #43", "several, by slug or number"},
|
||||
{"kettle close --reopen 42", "the same thing backwards"},
|
||||
{"kettle close --dry-run 42 43", "what would change; no request at all"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
reopen := fs.Bool("reopen", false, "set the state back to open instead of closed")
|
||||
dryRun := fs.Bool("dry-run", false, "print what would change; makes no request")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return Fail("name at least one issue: a slug, or 42, #42, owner/repo#42, a URL")
|
||||
}
|
||||
state, verb, past := "closed", "close", "closed"
|
||||
if *reopen {
|
||||
state, verb, past = "open", "reopen", "reopened"
|
||||
}
|
||||
|
||||
root, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger := closeLedger(root)
|
||||
|
||||
// Every argument is resolved before anything is sent, so a typo in
|
||||
// the third id does not leave the first two closed.
|
||||
var targets []closeTarget
|
||||
for _, arg := range args {
|
||||
t, err := closeResolve(arg, root, issues, ledger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !closeHas(targets, t) {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
for _, t := range targets {
|
||||
where := "no local copy"
|
||||
if i, ok := issues[t.id]; ok {
|
||||
where = fmt.Sprintf("%s (state: %s)", issue.PathOf(root, t.id), i.State)
|
||||
}
|
||||
fmt.Printf("would %-6s %-24s %-20s %s\n",
|
||||
verb, closeName(t.id), t.key.In(client.Repo()), where)
|
||||
}
|
||||
fmt.Printf("%d issue(s) would be %s; no request was made\n", len(targets), past)
|
||||
return nil
|
||||
}
|
||||
|
||||
touched := 0
|
||||
for _, t := range targets {
|
||||
c := client
|
||||
if !t.key.Repo.Zero() {
|
||||
c = client.For(t.key.Repo)
|
||||
}
|
||||
req := wire.IssueRequest{State: wire.Set(state)}
|
||||
got, err := c.EditIssue(t.key.Number, req, fmt.Sprintf("state-%d", t.key.Number))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The gate. Above it nothing local has been written; below it the
|
||||
// file is about to say something the tracker had better agree
|
||||
// with. An answer counts only when it is the very issue that was
|
||||
// patched, in the state that was asked for.
|
||||
if got.Number != t.key.Number || got.State != state {
|
||||
return Fail("%s: the %s did not go through — the tracker answered for issue #%d in state %q. Nothing local was changed.",
|
||||
t.key.In(c.Repo()), verb, got.Number, got.State)
|
||||
}
|
||||
fmt.Printf("%-8s %-24s %-20s %s\n",
|
||||
past, closeName(t.id), t.key.In(c.Repo()), got.HTMLURL)
|
||||
|
||||
i, ok := issues[t.id]
|
||||
if !ok {
|
||||
fmt.Printf(" no local copy — `kettle pull %d` to get one\n", t.key.Number)
|
||||
continue
|
||||
}
|
||||
path, err := closeApply(root, i, state, got)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" state: %s %s\n", state, path)
|
||||
touched++
|
||||
}
|
||||
|
||||
// Only when a file actually changed: INDEX.md is a view of the
|
||||
// directory, and rewriting it after a run that wrote nothing local
|
||||
// is a write nobody asked for.
|
||||
if touched > 0 {
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// closeTarget is one issue a run will act on: where it is in the tracker, and
|
||||
// what this machine calls it, when this machine has a name for it at all.
|
||||
type closeTarget struct {
|
||||
// id is the local slug, "" when nothing here names this issue. Closing one
|
||||
// of those is ordinary — push deletes the file it would have been named by.
|
||||
id string
|
||||
// key is the tracker address. Its Repo is zero only when the argument was a
|
||||
// bare number and no local copy or ledger entry qualified it, which means
|
||||
// this project's own repository.
|
||||
key wire.Key
|
||||
}
|
||||
|
||||
// closeEntry is one row of the number -> slug ledger, parsed.
|
||||
type closeEntry struct {
|
||||
key wire.Key
|
||||
slug string
|
||||
}
|
||||
|
||||
// closeLedger is `.remote.json` as pairs, sorted so two identical runs report
|
||||
// an ambiguity in the same order.
|
||||
//
|
||||
// Read rather than ignored because it is the only thing on this machine that
|
||||
// still names an issue push has dropped: the file is gone, the slug is not.
|
||||
func closeLedger(root string) []closeEntry {
|
||||
var out []closeEntry
|
||||
for raw, slug := range gitea.LoadRemoteMap(root) {
|
||||
k, err := wire.ParseKey(raw)
|
||||
if err != nil || k.Repo.Zero() || k.Number < 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, closeEntry{key: k, slug: slug})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].key.String() < out[j].key.String() })
|
||||
return out
|
||||
}
|
||||
|
||||
// closeResolve turns one argument into a target.
|
||||
//
|
||||
// The order is the order of what is most authoritative about this machine: a
|
||||
// file on disk, then the ledger, then nothing. A key is already the tracker's
|
||||
// answer, so the only thing still wanted for it is the slug — so that the local
|
||||
// copy, if there is one, can be kept honest — and the file that carries the
|
||||
// handle knows that before the ledger does.
|
||||
//
|
||||
// The repository travels with the number, because a key may name one and a
|
||||
// `gitea:` handle always does. Sending a foreign key to whatever repository
|
||||
// this project points at would close somebody else's issue of the same number.
|
||||
func closeResolve(arg, root string, issues map[string]*issue.Issue, ledger []closeEntry) (closeTarget, error) {
|
||||
// A key first, and a slug never looks like one: slugs hold no `#`, no `/`
|
||||
// and no `:`, so the two vocabularies cannot collide.
|
||||
if k, err := wire.ParseKey(arg); err == nil {
|
||||
var hits []closeEntry
|
||||
for id, i := range issues {
|
||||
if h, ok := mapping.RemoteKeyOf(i); ok && h.Number == k.Number && (k.Repo.Zero() || h.Repo == k.Repo) {
|
||||
hits = append(hits, closeEntry{key: h, slug: id})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(a, b int) bool { return hits[a].slug < hits[b].slug })
|
||||
if len(hits) == 0 {
|
||||
for _, e := range ledger {
|
||||
if e.key.Number == k.Number && (k.Repo.Zero() || e.key.Repo == k.Repo) {
|
||||
hits = append(hits, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
hit, err := closeOne(hits, arg, "a slug")
|
||||
if err != nil {
|
||||
return closeTarget{}, err
|
||||
}
|
||||
t := closeTarget{key: k}
|
||||
if hit != nil {
|
||||
t.id = hit.slug
|
||||
t.key = k.In(hit.key.Repo)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
if i, ok := issues[arg]; ok {
|
||||
k, ok := mapping.RemoteKeyOf(i)
|
||||
if !ok {
|
||||
return closeTarget{}, Fail("%s is not in the tracker (origin: %s, no usable `%s:` handle) — "+
|
||||
"there is no state there to change; `kettle push %s` first",
|
||||
arg, i.Origin, mapping.GiteaKey, arg)
|
||||
}
|
||||
return closeTarget{id: arg, key: k}, nil
|
||||
}
|
||||
|
||||
var hits []closeEntry
|
||||
for _, e := range ledger {
|
||||
if e.slug == arg {
|
||||
hits = append(hits, e)
|
||||
}
|
||||
}
|
||||
hit, err := closeOne(hits, arg, "a number")
|
||||
if err != nil {
|
||||
return closeTarget{}, err
|
||||
}
|
||||
if hit != nil {
|
||||
return closeTarget{id: arg, key: hit.key}, nil // pushed, and its file went with the push
|
||||
}
|
||||
return closeTarget{}, Fail("no issue %q in %s or in its %s — name a tracker key "+
|
||||
"(42, #42, owner/repo#42, or the issue's URL) to close one this machine has never seen",
|
||||
arg, root, gitea.RemoteMapName)
|
||||
}
|
||||
|
||||
// closeOne is the single ledger row for an argument, nil when the ledger knows
|
||||
// nothing about it, or an error when it knows two.
|
||||
//
|
||||
// Two answers mean one number (or one slug) under more than one repository, and
|
||||
// only a qualified key can settle that. Guessing would close the wrong issue.
|
||||
func closeOne(hits []closeEntry, arg, what string) (*closeEntry, error) {
|
||||
seen := map[string]bool{}
|
||||
var uniq []closeEntry
|
||||
for _, h := range hits {
|
||||
if k := h.key.String() + " " + h.slug; !seen[k] {
|
||||
seen[k] = true
|
||||
uniq = append(uniq, h)
|
||||
}
|
||||
}
|
||||
switch len(uniq) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
return &uniq[0], nil
|
||||
}
|
||||
var where []string
|
||||
for _, h := range uniq {
|
||||
where = append(where, h.key.String())
|
||||
}
|
||||
return nil, Fail("%q matches %s under more than one repository (%s) — say which, as owner/repo#N",
|
||||
arg, what, strings.Join(where, ", "))
|
||||
}
|
||||
|
||||
// closeApply writes the confirmed state onto the local file and returns its
|
||||
// path.
|
||||
//
|
||||
// `state:` is the domain's own field, so it is set on the issue and written out
|
||||
// by the domain's own writer. The sync-owned freshness fields travel with it:
|
||||
// the answer that authorized this write is also the newest thing the tracker has
|
||||
// said about the issue, so `synced:` and `remote-updated:` are stamped from it
|
||||
// rather than left describing an older read.
|
||||
func closeApply(root string, i *issue.Issue, state string, got *wire.Issue) (string, error) {
|
||||
i.State = state
|
||||
if i.Extra == nil {
|
||||
i.Extra = map[string]string{}
|
||||
}
|
||||
i.Extra[mapping.SyncedKey] = time.Now().UTC().Format(time.RFC3339)
|
||||
if got.UpdatedAt != "" {
|
||||
i.Extra[mapping.RemoteUpdatedKey] = got.UpdatedAt
|
||||
}
|
||||
return issue.Save(root, i)
|
||||
}
|
||||
|
||||
func closeHas(targets []closeTarget, t closeTarget) bool {
|
||||
for _, have := range targets {
|
||||
if have == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// closeName is what a receipt calls an issue this machine has no name for.
|
||||
func closeName(id string) string {
|
||||
if id == "" {
|
||||
return "(no local copy)"
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Package cmd is the kettle command tree.
|
||||
//
|
||||
// Commands are values, not init() side effects on a framework: each one carries
|
||||
// the metadata a human needs (what it does, what it takes, worked examples) in
|
||||
// the same struct that carries the code. That is deliberate — the plugin's
|
||||
// SKILL.md files are generated from this list, so a command whose flags changed
|
||||
// cannot ship with documentation that says otherwise.
|
||||
//
|
||||
// The tree is flat. `kettle new`, not `kettle issue new`: an agent pays for
|
||||
// every token of every invocation, and the grouping that matters for reading is
|
||||
// carried in Group and only shows up in the docs.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Groups, in the order they are presented. They name the layer a command
|
||||
// belongs to, which is the one thing a reader has to keep straight: the domain
|
||||
// works offline and the tracker does not exist to it.
|
||||
const (
|
||||
GroupProject = "project"
|
||||
GroupIssue = "issue"
|
||||
GroupSync = "sync"
|
||||
)
|
||||
|
||||
var groupOrder = []string{GroupProject, GroupIssue, GroupSync}
|
||||
|
||||
var groupBlurb = map[string]string{
|
||||
GroupProject: "the project itself",
|
||||
GroupIssue: "issues as units of work — offline, no tracker involved",
|
||||
GroupSync: "moving issues between the store and the tracker",
|
||||
}
|
||||
|
||||
// Example is one worked invocation. Both halves are shown in help and in the
|
||||
// generated skill docs.
|
||||
type Example struct {
|
||||
Cmd string
|
||||
What string
|
||||
}
|
||||
|
||||
// Command is one verb.
|
||||
type Command struct {
|
||||
// Name is what the user types.
|
||||
Name string
|
||||
// Group is the layer it belongs to; documentation only.
|
||||
Group string
|
||||
// Args is the positional-argument spec, e.g. "<id> [<id>…]".
|
||||
Args string
|
||||
// Short is one line, shown in the command list.
|
||||
Short string
|
||||
// Long is the full explanation, shown by `kettle help <name>`.
|
||||
Long string
|
||||
// Examples are worked invocations.
|
||||
Examples []Example
|
||||
// Setup registers this command's flags on fs and returns the function that
|
||||
// runs it, closing over them. Splitting it this way lets the doc generator
|
||||
// walk the flags without running anything.
|
||||
Setup func(fs *flag.FlagSet) func(args []string) error
|
||||
}
|
||||
|
||||
var registry []*Command
|
||||
|
||||
func register(c *Command) { registry = append(registry, c) }
|
||||
|
||||
// Commands lists every command, sorted by group and then by name.
|
||||
func Commands() []*Command {
|
||||
out := append([]*Command{}, registry...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
gi, gj := groupIndex(out[i].Group), groupIndex(out[j].Group)
|
||||
if gi != gj {
|
||||
return gi < gj
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Lookup finds a command by name.
|
||||
func Lookup(name string) *Command {
|
||||
for _, c := range registry {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flags returns this command's flags without running it — what the doc
|
||||
// generator walks.
|
||||
func (c *Command) Flags() []*flag.Flag {
|
||||
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
|
||||
fs.SetOutput(discard{})
|
||||
c.Setup(fs)
|
||||
var out []*flag.Flag
|
||||
fs.VisitAll(func(f *flag.Flag) { out = append(out, f) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Usage is the one-line synopsis.
|
||||
func (c *Command) Usage() string {
|
||||
s := "kettle " + c.Name
|
||||
if c.Args != "" {
|
||||
s += " " + c.Args
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SilentError carries an exit status for a command that has already said
|
||||
// everything it has to say. `check` uses it: findings went to stdout and a
|
||||
// second copy on stderr would be noise.
|
||||
type SilentError struct{ Code int }
|
||||
|
||||
func (e SilentError) Error() string { return "" }
|
||||
|
||||
// Fail is the error every command returns for an ordinary failure. Main
|
||||
// prefixes it with the command name.
|
||||
func Fail(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
|
||||
// Main runs argv (without the program name) and returns the exit status.
|
||||
func Main(argv []string) int {
|
||||
if len(argv) == 0 {
|
||||
printUsage(os.Stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
name := argv[0]
|
||||
switch name {
|
||||
case "help", "-h", "--help":
|
||||
if len(argv) > 1 {
|
||||
c := Lookup(argv[1])
|
||||
if c == nil {
|
||||
fmt.Fprintf(os.Stderr, "kettle: no command %q\n", argv[1])
|
||||
return 2
|
||||
}
|
||||
printCommand(os.Stdout, c)
|
||||
return 0
|
||||
}
|
||||
printUsage(os.Stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
c := Lookup(name)
|
||||
if c == nil {
|
||||
fmt.Fprintf(os.Stderr, "kettle: no command %q — try `kettle help`\n", name)
|
||||
return 2
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.Usage = func() { printCommand(os.Stderr, c) }
|
||||
run := c.Setup(fs)
|
||||
if err := fs.Parse(permute(fs, argv[1:])); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
switch err := run(fs.Args()).(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case SilentError:
|
||||
return err.Code
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "kettle %s: %v\n", name, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage(w *os.File) {
|
||||
fmt.Fprint(w, "kettle — issues as local markdown, and the tracker they sync with\n\n")
|
||||
fmt.Fprint(w, "usage: kettle <command> [flags] [args]\n")
|
||||
|
||||
current := ""
|
||||
for _, c := range Commands() {
|
||||
if c.Group != current {
|
||||
current = c.Group
|
||||
fmt.Fprintf(w, "\n%s — %s\n", current, groupBlurb[current])
|
||||
}
|
||||
fmt.Fprintf(w, " %-11s %s\n", c.Name, c.Short)
|
||||
}
|
||||
fmt.Fprint(w, "\n`kettle help <command>` for one command in full.\n")
|
||||
}
|
||||
|
||||
func printCommand(w *os.File, c *Command) {
|
||||
fmt.Fprintf(w, "%s\n\n%s\n", c.Usage(), c.Short)
|
||||
if c.Long != "" {
|
||||
fmt.Fprintf(w, "\n%s\n", strings.TrimSpace(c.Long))
|
||||
}
|
||||
if flags := c.Flags(); len(flags) > 0 {
|
||||
fmt.Fprint(w, "\nflags:\n")
|
||||
for _, f := range flags {
|
||||
name := "--" + f.Name
|
||||
if f.DefValue != "" && f.DefValue != "false" {
|
||||
name += "=" + f.DefValue
|
||||
}
|
||||
fmt.Fprintf(w, " %-22s %s\n", name, f.Usage)
|
||||
}
|
||||
}
|
||||
if len(c.Examples) > 0 {
|
||||
fmt.Fprint(w, "\nexamples:\n")
|
||||
for _, e := range c.Examples {
|
||||
fmt.Fprintf(w, " %s\n %s\n", e.Cmd, e.What)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permute moves flags ahead of positional arguments.
|
||||
//
|
||||
// The standard flag package stops parsing at the first non-flag argument, so
|
||||
// `kettle ac <id> --check 3` would hand --check to the command as a positional
|
||||
// and tick nothing. Every other CLI an operator uses interleaves the two, and
|
||||
// a tool that silently ignores a flag because of where it was typed is worse
|
||||
// than one that rejects it.
|
||||
//
|
||||
// A flag that takes a value swallows the next argument, which is why this needs
|
||||
// the FlagSet: only the set knows whether --check wants one. `--` ends the
|
||||
// permutation, and everything after it is positional whatever it looks like.
|
||||
func permute(fs *flag.FlagSet, args []string) []string {
|
||||
var flags, positional []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
positional = append(positional, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
positional = append(positional, a)
|
||||
continue
|
||||
}
|
||||
flags = append(flags, a)
|
||||
if strings.Contains(a, "=") {
|
||||
continue
|
||||
}
|
||||
f := fs.Lookup(strings.TrimLeft(a, "-"))
|
||||
// An unknown flag consumes nothing; Parse will reject it by name in a
|
||||
// moment, which is a better message than one about its value.
|
||||
if f == nil || isBoolFlag(f.Value) {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
return append(flags, positional...)
|
||||
}
|
||||
|
||||
func isBoolFlag(v flag.Value) bool {
|
||||
b, ok := v.(interface{ IsBoolFlag() bool })
|
||||
return ok && b.IsBoolFlag()
|
||||
}
|
||||
|
||||
func groupIndex(g string) int {
|
||||
for i, name := range groupOrder {
|
||||
if name == g {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(groupOrder)
|
||||
}
|
||||
|
||||
type discard struct{}
|
||||
|
||||
func (discard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
@@ -0,0 +1,146 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "comment",
|
||||
Group: GroupSync,
|
||||
Args: "<id>",
|
||||
Short: "post or edit a comment on a synced issue",
|
||||
Long: `The target is a LOCAL ID, not a number. Which issue this is, is a fact about the
|
||||
work; where it lives in the tracker is bookkeeping, and the ` + "`gitea:`" + ` handle on the
|
||||
file is what turns one into the other. An ` + "`origin: local`" + ` issue cannot be
|
||||
commented on at all — it is not in the tracker, so there is nothing there to
|
||||
comment on; push it first.
|
||||
|
||||
The body comes from a file or from --body, and multi-line prose is what --file
|
||||
is for. This is why comments go through the API rather than through a tracker
|
||||
CLI: an entity command with an empty-looking positional opens $EDITOR, and on a
|
||||
TTY that does not exist it hangs forever.
|
||||
|
||||
After the write the whole thread is refetched into ` + "`<id>.comments.md`" + `, so the
|
||||
local copy is not stale by one comment — the one this run just made.
|
||||
|
||||
COMMENTS ARE PULL-ONLY IN THE STORE. Nothing round-trips them back: editing
|
||||
` + "`<id>.comments.md`" + ` by hand changes nothing in the tracker. Use --edit with a
|
||||
comment id for that.`,
|
||||
Examples: []Example{
|
||||
{"kettle comment wire-sqlc-appclick --file notes.md", "post the contents of a file"},
|
||||
{`kettle comment wire-sqlc-appclick --body "готово, задеплоено"`, "post one line"},
|
||||
{"kettle comment wire-sqlc-appclick --file fix.md --edit 1234", "rewrite comment 1234 instead"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
file := fs.String("file", "", "markdown file holding the comment body")
|
||||
body := fs.String("body", "", "comment body inline (short, single-line)")
|
||||
edit := fs.Int64("edit", 0, "comment id to rewrite, instead of posting a new one")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return Fail("give exactly one issue id")
|
||||
}
|
||||
id := args[0]
|
||||
withFile, withBody := wasSet(fs, "file"), wasSet(fs, "body")
|
||||
switch {
|
||||
case withFile && withBody:
|
||||
return Fail("--file and --body are mutually exclusive")
|
||||
case !withFile && !withBody:
|
||||
return Fail("give the comment body: --file <path>, or --body \"…\"")
|
||||
}
|
||||
|
||||
root, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i, err := issue.Load(root, id)
|
||||
if err != nil {
|
||||
return Fail("no issue %q in %s", id, root)
|
||||
}
|
||||
key, ok := mapping.RemoteKeyOf(i)
|
||||
if !ok {
|
||||
return Fail("%s is local-only (origin: %s, no usable `%s:` handle) — "+
|
||||
"there is nothing in the tracker to comment on; `kettle push %s` first",
|
||||
id, i.Origin, mapping.GiteaKey, id)
|
||||
}
|
||||
text, err := commentBodyFrom(*file, *body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The handle names the repository, so a comment lands where the
|
||||
// issue actually is — even when the store has ever pointed at two.
|
||||
client = client.For(key.Repo)
|
||||
|
||||
var got *wire.Comment
|
||||
verb := "posted"
|
||||
if *edit != 0 {
|
||||
verb = "edited"
|
||||
got, err = client.EditComment(*edit, text, fmt.Sprintf("comment-%d", *edit))
|
||||
} else {
|
||||
got, err = client.CreateComment(key.Number, text, "comment-"+id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A 2xx that carries no id is not a comment. Nothing local has been
|
||||
// written yet, and nothing will be if the answer is that shape.
|
||||
if got.ID == 0 {
|
||||
return Fail("the %s answer carries no comment id — nothing local was changed", verb)
|
||||
}
|
||||
fmt.Printf("%s comment %d on %s (%s) %s\n", verb, got.ID, id, key, got.HTMLURL)
|
||||
|
||||
comments, err := client.ListComments(key.Number)
|
||||
if err != nil {
|
||||
return Fail("the comment went up, but refetching the thread failed: %v — "+
|
||||
"`kettle pull %d` to refresh the local copy", err, key.Number)
|
||||
}
|
||||
path := commentsSidecarPath(root, id)
|
||||
if len(comments) == 0 {
|
||||
// Only reachable when the thread was emptied elsewhere between
|
||||
// the write and the read. A stale sidecar for a thread that no
|
||||
// longer exists is worse than no sidecar.
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("thread: none — %s removed\n", path)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(mapping.RenderComments(comments)), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("thread: %s (%d comment(s))\n", path, len(comments))
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// commentBodyFrom reads the comment body from a file or takes it as given.
|
||||
//
|
||||
// Trimmed and then required to be non-empty: a file of whitespace is somebody
|
||||
// pointing at the wrong path, and posting it would leave an empty comment in a
|
||||
// thread that nobody can delete from here.
|
||||
func commentBodyFrom(file, inline string) (string, error) {
|
||||
if file != "" {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", Fail("cannot read the comment body: %v", err)
|
||||
}
|
||||
inline = string(raw)
|
||||
}
|
||||
text := strings.TrimSpace(inline)
|
||||
if text == "" {
|
||||
return "", Fail("the comment body is empty — nothing was posted")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "config",
|
||||
Group: GroupProject,
|
||||
Short: "show what this project resolved to",
|
||||
Long: `Every path and every setting, with the overrides already applied, so a run that
|
||||
went somewhere unexpected can be explained without guessing.
|
||||
|
||||
The token is never printed — only whether one was found.
|
||||
|
||||
This is the command to reach for when the store looks empty, when a push says
|
||||
401, or when two directories disagree about which project they are in.`,
|
||||
Examples: []Example{
|
||||
{"kettle config", "resolved paths and settings"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
return func(args []string) error {
|
||||
root := project.Root("")
|
||||
if root == "" {
|
||||
return project.NotFoundError("")
|
||||
}
|
||||
|
||||
fmt.Printf("project %s\n", root)
|
||||
fmt.Printf("store %s\n", issue.Root(""))
|
||||
fmt.Printf("payload %s\n", project.PayloadRoot(""))
|
||||
fmt.Printf("config %s\n", config.ProjectPath(""))
|
||||
fmt.Printf("logins %s\n", config.LoginsPath())
|
||||
|
||||
r, err := config.Resolve("")
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return err
|
||||
}
|
||||
red := r.Redacted()
|
||||
fmt.Println()
|
||||
fmt.Printf("login %s\n", orNone(red.Login))
|
||||
fmt.Printf("url %s\n", orNone(red.URL))
|
||||
fmt.Printf("token %s\n", orNone(red.Token))
|
||||
if r.Owner != "" {
|
||||
fmt.Printf("repo %s\n", r.Slug())
|
||||
} else {
|
||||
fmt.Printf("repo none\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "evict",
|
||||
Group: GroupIssue,
|
||||
Args: "[<id>…]",
|
||||
Short: "remove closed issues from the local store",
|
||||
Long: `The store is a working set, not an archive. What is evicted is two conditions,
|
||||
both read off the file:
|
||||
|
||||
state: closed the work is done
|
||||
origin: <tracker> the work is somewhere else too
|
||||
|
||||
THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. ` + "`origin: local`" + ` means this
|
||||
file IS the issue — there is no other copy and deleting it deletes the work. It
|
||||
is never evicted, in any state, not even when named explicitly on the command
|
||||
line: a closed local issue is reported and kept.
|
||||
|
||||
Eviction asks the file rather than the tracker, because state and origin are
|
||||
domain fields and the answer is already in the store — which is why this needs
|
||||
no network and no login. ` + "`kettle sync-evict`" + ` is the variant that refreshes state
|
||||
from the tracker first and then makes the same decision.
|
||||
|
||||
Not a one-off migration: a pull by number fetches an issue in any state, so a
|
||||
closed issue pulled after an eviction lands on disk again. Evict it again when
|
||||
you are done with it.
|
||||
|
||||
INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug
|
||||
ledger is deliberately not pruned: its entries outlive the files they name, and
|
||||
that is what makes a pull land on the same slug afterwards.`,
|
||||
Examples: []Example{
|
||||
{"kettle evict", "every closed issue that is not origin: local"},
|
||||
{"kettle evict old-thing another-thing", "only these"},
|
||||
{"kettle evict --dry-run", "print what would go; touch nothing"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
dryRun := fs.Bool("dry-run", false, "print what would be removed; touch nothing")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !issue.StoreExists(root) {
|
||||
return Fail("store %s does not exist — nothing to evict", root)
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var missing []string
|
||||
for _, id := range args {
|
||||
if _, ok := issues[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
rep, err := issue.Evict(root, issues, args, *dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printEviction(rep, len(args) > 0)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func printEviction(rep *issue.EvictReport, named bool) {
|
||||
verb := "evicted"
|
||||
if rep.DryRun {
|
||||
verb = "would evict"
|
||||
}
|
||||
for _, e := range rep.Evicted {
|
||||
fmt.Printf("%-11s %s\n", verb, e.ID)
|
||||
for _, p := range e.Paths {
|
||||
fmt.Printf(" %s\n", p)
|
||||
}
|
||||
}
|
||||
for _, k := range rep.Kept {
|
||||
// An open issue is the normal case and says nothing worth a line —
|
||||
// unless the operator named it, in which case they are owed the reason.
|
||||
if k.Open && !named {
|
||||
continue
|
||||
}
|
||||
if k.Open {
|
||||
fmt.Printf("%-11s %s %s\n", "kept", k.ID, k.Why)
|
||||
} else {
|
||||
fmt.Printf("%-11s %s closed, %s\n", "kept", k.ID, k.Why)
|
||||
}
|
||||
}
|
||||
|
||||
if rep.DryRun {
|
||||
fmt.Printf("%d issue(s) would be evicted, %d kept — nothing was touched\n",
|
||||
len(rep.Evicted), len(rep.Kept))
|
||||
return
|
||||
}
|
||||
fmt.Printf("%d issue(s) evicted, %d kept\n", len(rep.Evicted), len(rep.Kept))
|
||||
if rep.IndexPath != "" {
|
||||
fmt.Printf("index: %s — %d issue(s)\n", rep.IndexPath, rep.IndexCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "sync-evict",
|
||||
Group: GroupSync,
|
||||
Args: "[<id>…]",
|
||||
Short: "refresh state from the tracker, then evict what is closed",
|
||||
Long: `` + "`kettle evict`" + ` is the command that decides and deletes. This adds exactly one
|
||||
thing in front of it: a ` + "`state:`" + ` that is not stale. A local ` + "`state:`" + ` is only as
|
||||
fresh as the last pull, so an issue closed in the web UI an hour ago still reads
|
||||
` + "`open`" + ` here and the offline command will — correctly — leave it alone. That is
|
||||
the gap this closes, and before it existed the operator had to pull the five
|
||||
closed issues back onto disk before anything could remove them.
|
||||
|
||||
ORDER OF OPERATIONS, AND IT IS THE WHOLE SAFETY ARGUMENT:
|
||||
|
||||
1. every candidate's state is fetched — ALL of them, before anything is
|
||||
removed;
|
||||
2. each answer must be the issue that was asked about, in a state the domain
|
||||
recognizes;
|
||||
3. only then is the eviction run, by handing the refreshed issues to the
|
||||
domain — the same decision, the same deletion, the same protection of
|
||||
` + "`origin: local`" + `, in one place.
|
||||
|
||||
A dead connection, a non-2xx, an answer about another issue, a state nobody
|
||||
recognizes: the run stops at step 2 and NOTHING is deleted, not even the issues
|
||||
whose answers had already arrived. That is stricter than push, which deletes as
|
||||
it goes, and it costs nothing here — there is no ordering constraint between
|
||||
evictions, so there is no reason to start before every answer is in.
|
||||
|
||||
A candidate is an issue carrying a ` + "`gitea:`" + ` handle. ` + "`origin: local`" + ` work has
|
||||
none, is never asked about, and is never evicted — it is not in the tracker to
|
||||
be closed. A tracked issue whose handle is missing or unreadable cannot be
|
||||
verified, so it is reported and kept rather than guessed at.
|
||||
|
||||
Cost: one request per candidate. The store is a working set that push keeps
|
||||
small, and a wrong answer here deletes a file, so each issue is asked about by
|
||||
its own address rather than inferred from a list a limit could have truncated.
|
||||
|
||||
The refreshed state is written back even for the issues that stay: the answer is
|
||||
already paid for, and a store that keeps a state the tracker has disowned is the
|
||||
thing this command exists to fix.`,
|
||||
Examples: []Example{
|
||||
{"kettle sync-evict", "ask about every synced issue; evict the closed ones"},
|
||||
{"kettle sync-evict old-thing another-thing", "only these"},
|
||||
{"kettle sync-evict --dry-run", "ask, report, write and delete nothing"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
dryRun := fs.Bool("dry-run", false, "ask the tracker and report; write and delete nothing")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var missing []string
|
||||
for _, id := range args {
|
||||
if _, ok := issues[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
checkable, unverifiable, local := syncEvictCandidates(issues, args)
|
||||
for _, s := range unverifiable {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %s — kept, and not asked about\n", s.id, s.why)
|
||||
}
|
||||
// A local issue is reported only when the operator named it: they
|
||||
// asked about this file by name and are owed the reason it stayed.
|
||||
if len(args) > 0 {
|
||||
for _, s := range local {
|
||||
fmt.Printf("%-11s %s %s\n", "kept", s.id, s.why)
|
||||
}
|
||||
}
|
||||
if len(checkable) == 0 {
|
||||
fmt.Println("nothing to check: nothing named carries a `gitea:` handle")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Every answer first, deletions after.
|
||||
fresh := make(map[string]string, len(checkable))
|
||||
for _, c := range checkable {
|
||||
got, err := client.For(c.key.Repo).GetIssue(c.key.Number)
|
||||
if err != nil {
|
||||
return Fail("%s: asking the tracker about %s failed: %v\nNothing was evicted.",
|
||||
c.id, c.key, err)
|
||||
}
|
||||
state, ok := syncEvictConfirms(got, c.key.Number)
|
||||
if !ok {
|
||||
return Fail("%s: the answer for %s does not confirm a state "+
|
||||
"(issue #%d, state %q). Nothing was evicted.",
|
||||
c.id, c.key, got.Number, got.State)
|
||||
}
|
||||
fresh[c.id] = state
|
||||
}
|
||||
|
||||
// The store stops lying even about the issues that stay. This is
|
||||
// the only write made before the decision, and a dry run makes
|
||||
// none.
|
||||
changed := 0
|
||||
for _, c := range checkable {
|
||||
was := issues[c.id].State
|
||||
if was == fresh[c.id] {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-11s %s %s -> %s\n", "state", c.id, was, fresh[c.id])
|
||||
issues[c.id].State = fresh[c.id]
|
||||
if *dryRun {
|
||||
continue
|
||||
}
|
||||
if _, err := issue.Save(root, issues[c.id]); err != nil {
|
||||
return err
|
||||
}
|
||||
changed++
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(checkable))
|
||||
for _, c := range checkable {
|
||||
ids = append(ids, c.id)
|
||||
}
|
||||
rep, err := issue.Evict(root, issues, ids, *dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printEviction(rep, len(args) > 0)
|
||||
|
||||
// Evict rebuilds INDEX.md when something went; a state written back
|
||||
// without an eviction changed the store too, and the index is a
|
||||
// view of it. Neither happening means nothing changed on disk, and
|
||||
// then nothing is rewritten.
|
||||
if changed > 0 && rep.IndexPath == "" {
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// syncEvictTarget is one issue the tracker can be asked about, and the address
|
||||
// to ask at — its own, so an issue that lives in another repository is asked
|
||||
// about there.
|
||||
type syncEvictTarget struct {
|
||||
id string
|
||||
key wire.Key
|
||||
}
|
||||
|
||||
// syncEvictSkip is an issue that was not asked about, with the reason.
|
||||
type syncEvictSkip struct{ id, why string }
|
||||
|
||||
// syncEvictCandidates splits the store into what the tracker can be asked
|
||||
// about, what names a tracker but cannot be reached, and what is local.
|
||||
//
|
||||
// An unverifiable issue names a tracker but carries no handle to reach it by,
|
||||
// which is a file to report and never one to delete on a guess. A local issue is
|
||||
// in neither of those: it has no handle because it has never left this machine,
|
||||
// and asking about it is not a question that has an answer.
|
||||
//
|
||||
// ids restricts the question to those issues; empty asks about the whole store.
|
||||
func syncEvictCandidates(issues map[string]*issue.Issue, ids []string) (checkable []syncEvictTarget, unverifiable, local []syncEvictSkip) {
|
||||
chosen := ids
|
||||
if len(chosen) == 0 {
|
||||
for id := range issues {
|
||||
chosen = append(chosen, id)
|
||||
}
|
||||
sort.Strings(chosen)
|
||||
}
|
||||
for _, id := range chosen {
|
||||
i, ok := issues[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i.IsLocal() {
|
||||
local = append(local, syncEvictSkip{id, issue.LocalReason})
|
||||
continue
|
||||
}
|
||||
key, ok := mapping.RemoteKeyOf(i)
|
||||
if !ok {
|
||||
unverifiable = append(unverifiable, syncEvictSkip{id,
|
||||
fmt.Sprintf("origin: %s but no usable `%s:` handle", i.Origin, mapping.GiteaKey)})
|
||||
continue
|
||||
}
|
||||
checkable = append(checkable, syncEvictTarget{id: id, key: key})
|
||||
}
|
||||
return checkable, unverifiable, local
|
||||
}
|
||||
|
||||
// syncEvictConfirms is the state the tracker confirmed for this number — the
|
||||
// deletion gate.
|
||||
//
|
||||
// Deliberately boring, and saying no by default, because everything downstream
|
||||
// of a yes here may delete a file. An answer counts only when it is about the
|
||||
// very issue that was asked about and names a state the domain recognizes. A
|
||||
// non-2xx never reaches this: the transport has already returned an error.
|
||||
func syncEvictConfirms(got *wire.Issue, number int) (string, bool) {
|
||||
if got == nil || got.Number != number {
|
||||
return "", false
|
||||
}
|
||||
for _, s := range issue.States {
|
||||
if got.State == s {
|
||||
return got.State, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// storeFlag registers the one flag almost every command has.
|
||||
//
|
||||
// An explicit --out overrides the resolved store and is used exactly as typed:
|
||||
// a relative --out stays relative to the working directory, because that is
|
||||
// what the operator asked for.
|
||||
func storeFlag(fs *flag.FlagSet) *string {
|
||||
return fs.String("out", "", "store root (default: <project>/.kettle/issues)")
|
||||
}
|
||||
|
||||
// storeRoot resolves the store, or explains which directories were searched.
|
||||
//
|
||||
// No marker anywhere is an answer, not a fallback: a store placed in a
|
||||
// plausible-looking directory is the failure the marker exists to replace.
|
||||
func storeRoot(out string) (string, error) {
|
||||
if root := issue.Root(out); root != "" {
|
||||
return root, nil
|
||||
}
|
||||
return "", project.NotFoundError("")
|
||||
}
|
||||
|
||||
// wasSet reports whether the operator actually typed this flag.
|
||||
//
|
||||
// Needed wherever the empty string is a legitimate value to reject rather than
|
||||
// a synonym for "not given": `--check ""` is an empty selector and an error,
|
||||
// while no --check at all means "just list the boxes".
|
||||
func wasSet(fs *flag.FlagSet, name string) bool {
|
||||
found := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// stringList is a repeatable flag: --label tech/sql --label comp/appclick.
|
||||
type stringList []string
|
||||
|
||||
func (l *stringList) String() string { return strings.Join(*l, ", ") }
|
||||
|
||||
func (l *stringList) Set(v string) error {
|
||||
*l = append(*l, v)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// The region markers. What sits between them belongs to the generator; the
|
||||
// rest of the file belongs to whoever wrote it.
|
||||
const (
|
||||
genOpen = "<!-- kettle:gen -->"
|
||||
genClose = "<!-- /kettle:gen -->"
|
||||
)
|
||||
|
||||
// genBanner opens every generated region. The first thing anybody who finds
|
||||
// the block wants to do is edit it in place, so the block says who wrote it and
|
||||
// which command writes it again.
|
||||
const genBanner = "**Generated from the kettle command registry by `kettle gen skills`.** " +
|
||||
"Everything between the two markers is replaced on the next run — " +
|
||||
"hand-written prose belongs outside them."
|
||||
|
||||
// exampleAlign is the widest example command that still gets its `# what`
|
||||
// padded into a column. One long pipeline would otherwise push every other
|
||||
// comment off the right edge of the page.
|
||||
const exampleAlign = 56
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "gen",
|
||||
Group: GroupProject,
|
||||
Args: "skills",
|
||||
Short: "write the plugin's SKILL.md files from the command registry",
|
||||
Long: `A SKILL.md tells an agent how to invoke this binary. Hand-written, it drifts: a
|
||||
flag is renamed here and the documentation goes on recommending the old one,
|
||||
and the agent that reads it fails in a way nobody traces back to a stale
|
||||
sentence. Everything those files say about a command — its usage line, its
|
||||
flags with their defaults, its worked examples — is already in the registry
|
||||
this binary is built from, so it is written from there and cannot disagree.
|
||||
|
||||
THE GENERATOR OWNS A REGION, NOT A FILE. Each SKILL.md carries a pair of HTML
|
||||
comment markers — ` + "`kettle:gen`" + ` to open and ` + "`/kettle:gen`" + ` to close, both written in
|
||||
the ` + "`<!-- … -->`" + ` form and visible at the top and bottom of the block below.
|
||||
Everything between them is replaced on every run; every byte outside them comes
|
||||
back exactly as it was, which matters most for ` + "`description:`" + `, the prose that
|
||||
decides whether an agent loads the skill at all, and the one thing here that no
|
||||
generator can write.
|
||||
|
||||
A file with no markers is REPORTED AND LEFT ALONE, never overwritten: clobbering
|
||||
somebody's prose because they forgot a marker is the failure this design exists
|
||||
to prevent. A file that does not exist yet is created with a frontmatter stub
|
||||
around a generated block, for a human to fill in.
|
||||
|
||||
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||
regenerating something that has not changed produces no diff. --check is that
|
||||
property made useful: it writes nothing and exits 1 when any file on disk
|
||||
differs from what would be generated, which is what a pre-commit hook or a CI
|
||||
step calls. It wins over --dry-run when both are given.`,
|
||||
Examples: []Example{
|
||||
{"kettle gen skills --out ../plugins/tea/skills", "write the region in every group's SKILL.md"},
|
||||
{"kettle gen skills --out ../plugins/tea/skills --dry-run", "print what would change; write nothing"},
|
||||
{"kettle gen skills --out ../plugins/tea/skills --check", "exit 1 if the docs are out of date"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
|
||||
dryRun := fs.Bool("dry-run", false, "print what would change; write nothing")
|
||||
check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date")
|
||||
|
||||
return func(args []string) error {
|
||||
target := "skills"
|
||||
if len(args) > 0 {
|
||||
target = args[0]
|
||||
}
|
||||
if len(args) > 1 || target != "skills" {
|
||||
return Fail("the only target is `skills` — try `kettle gen skills --out <dir>`")
|
||||
}
|
||||
if *out == "" {
|
||||
return Fail("--out is required — the directory the SKILL.md files live under")
|
||||
}
|
||||
return genSkills(*out, *dryRun, *check)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// errNoRegion is what a file that the generator may not touch reports.
|
||||
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
|
||||
|
||||
func genSkills(dir string, dryRun, check bool) error {
|
||||
// --check is a read-only question about the working tree, so it overrules
|
||||
// --dry-run rather than combining with it.
|
||||
if check {
|
||||
dryRun = true
|
||||
}
|
||||
|
||||
groups := docGroups()
|
||||
var written, unchanged, outdated, kept int
|
||||
for _, group := range groups {
|
||||
path := filepath.Join(dir, group, "SKILL.md")
|
||||
block, err := renderGroup(commandsIn(group))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
outdated++
|
||||
if check {
|
||||
fmt.Printf("%-13s %s\n", "missing", path)
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("%-13s %s\n", "would create", path)
|
||||
continue
|
||||
}
|
||||
if err := writeFile(path, stubFile(group, block)); err != nil {
|
||||
return err
|
||||
}
|
||||
written++
|
||||
fmt.Printf("%-13s %s\n", "created", path)
|
||||
|
||||
case err != nil:
|
||||
return err
|
||||
|
||||
default:
|
||||
want, err := spliceRegion(string(existing), block)
|
||||
if err != nil {
|
||||
// Reported, never repaired: a missing marker is somebody's
|
||||
// prose sitting where the block used to be.
|
||||
kept++
|
||||
fmt.Fprintf(os.Stderr, "kettle gen: %s left alone — %v\n", path, err)
|
||||
continue
|
||||
}
|
||||
if want == string(existing) {
|
||||
unchanged++
|
||||
fmt.Printf("%-13s %s\n", "unchanged", path)
|
||||
continue
|
||||
}
|
||||
outdated++
|
||||
if check {
|
||||
fmt.Printf("%-13s %s\n", "stale", path)
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("%-13s %s\n", "would update", path)
|
||||
continue
|
||||
}
|
||||
if err := writeFile(path, want); err != nil {
|
||||
return err
|
||||
}
|
||||
written++
|
||||
fmt.Printf("%-13s %s\n", "updated", path)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case check:
|
||||
fmt.Printf("%d file(s) checked, %d out of date, %d without a region\n",
|
||||
len(groups), outdated, kept)
|
||||
if outdated > 0 {
|
||||
fmt.Printf("run `kettle gen skills --out %s`\n", dir)
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
case dryRun:
|
||||
fmt.Printf("%d file(s) would change, %d unchanged, %d without a region — nothing was written\n",
|
||||
outdated, unchanged, kept)
|
||||
default:
|
||||
fmt.Printf("%d file(s) written, %d unchanged, %d without a region\n", written, unchanged, kept)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// docGroups lists the groups that have commands, in the order Commands()
|
||||
// returns them — the same order twice, so two runs cannot differ.
|
||||
func docGroups() []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, c := range Commands() {
|
||||
if c.Group == "" {
|
||||
fmt.Fprintf(os.Stderr, "kettle gen: command %q has no group and is in no skill\n", c.Name)
|
||||
continue
|
||||
}
|
||||
if !seen[c.Group] {
|
||||
seen[c.Group] = true
|
||||
out = append(out, c.Group)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func commandsIn(group string) []*Command {
|
||||
var out []*Command
|
||||
for _, c := range Commands() {
|
||||
if c.Group == group {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderGroup is the generated block for one group, without the markers and
|
||||
// without a trailing newline.
|
||||
func renderGroup(cmds []*Command) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString(genBanner)
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, c := range cmds {
|
||||
text := renderCommand(c)
|
||||
// A block holding either marker would cut itself in half on the next
|
||||
// run — the splice would end the region in the middle of the prose that
|
||||
// mentions it. Loud here rather than quietly truncated on disk.
|
||||
if strings.Contains(text, genOpen) || strings.Contains(text, genClose) {
|
||||
return "", Fail("command %q spells a region marker out in full; the generated block would then end inside itself — write it another way", c.Name)
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
func renderCommand(c *Command) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "\n## `%s`\n\n%s\n", c.Usage(), c.Short)
|
||||
if long := strings.TrimSpace(c.Long); long != "" {
|
||||
b.WriteString("\n" + long + "\n")
|
||||
}
|
||||
if flags := c.Flags(); len(flags) > 0 {
|
||||
b.WriteString("\n| flag | default | what it does |\n| --- | --- | --- |\n")
|
||||
for _, f := range flags {
|
||||
fmt.Fprintf(&b, "| `--%s` | %s | %s |\n", f.Name, defaultCell(f.DefValue), cell(f.Usage))
|
||||
}
|
||||
}
|
||||
if len(c.Examples) > 0 {
|
||||
w := exampleWidth(c.Examples)
|
||||
b.WriteString("\n```bash\n")
|
||||
for _, e := range c.Examples {
|
||||
pad := w - utf8.RuneCountInString(e.Cmd)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s # %s\n", e.Cmd, strings.Repeat(" ", pad), e.What)
|
||||
}
|
||||
b.WriteString("```\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// exampleWidth is the column the `# what` comments line up at. Runes, not
|
||||
// bytes: an example with Cyrillic in it would otherwise pull the column left by
|
||||
// however many multi-byte characters it holds.
|
||||
func exampleWidth(examples []Example) int {
|
||||
w := 0
|
||||
for _, e := range examples {
|
||||
if n := utf8.RuneCountInString(e.Cmd); n > w && n <= exampleAlign {
|
||||
w = n
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func defaultCell(v string) string {
|
||||
if v == "" {
|
||||
return "—"
|
||||
}
|
||||
return "`" + cell(v) + "`"
|
||||
}
|
||||
|
||||
// cell keeps a value from breaking out of its table row.
|
||||
func cell(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.ReplaceAll(s, "|", `\|`)
|
||||
}
|
||||
|
||||
func region(block string) string {
|
||||
return genOpen + "\n" + block + "\n" + genClose
|
||||
}
|
||||
|
||||
// spliceRegion swaps the block into existing, leaving every other byte alone.
|
||||
func spliceRegion(existing, block string) (string, error) {
|
||||
start := strings.Index(existing, genOpen)
|
||||
if start < 0 {
|
||||
return "", errNoRegion
|
||||
}
|
||||
rest := start + len(genOpen)
|
||||
end := strings.Index(existing[rest:], genClose)
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("%s is missing its %s", genOpen, genClose)
|
||||
}
|
||||
return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil
|
||||
}
|
||||
|
||||
// stubFile is a new SKILL.md: the least frontmatter that is still a skill,
|
||||
// and the region.
|
||||
//
|
||||
// The description is left as a TODO on purpose. It is the sentence that decides
|
||||
// whether an agent loads this skill at all — prose a human tunes against real
|
||||
// failures to trigger, and the one thing here a generator has no way to write.
|
||||
func stubFile(group, block string) string {
|
||||
title := "# kettle " + group + "\n"
|
||||
if blurb := groupBlurb[group]; blurb != "" {
|
||||
title += "\n" + blurb + "\n"
|
||||
}
|
||||
return "---\n" +
|
||||
"name: " + group + "\n" +
|
||||
"description: TODO — write this by hand. It is the only thing that decides whether an agent loads this skill at all, so it is prose a human tunes; kettle gen never reads or writes it.\n" +
|
||||
"---\n\n" +
|
||||
title + "\n" +
|
||||
region(block) + "\n"
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package cmd_test
|
||||
|
||||
// `kettle gen` writes documentation an agent reads to invoke this binary, into
|
||||
// files a human also writes prose in. Both halves of that are tested here: what
|
||||
// it produces has to be the same twice over, and what it does NOT own has to
|
||||
// come back byte for byte.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
genOpen = "<!-- kettle:gen -->"
|
||||
genClose = "<!-- /kettle:gen -->"
|
||||
)
|
||||
|
||||
func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
|
||||
first := mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
for _, group := range []string{"project", "issue", "sync"} {
|
||||
path := filepath.Join(out, group, "SKILL.md")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s was not created: %v\n%s", path, err, first.out())
|
||||
}
|
||||
body := string(raw)
|
||||
// The frontmatter is what makes it a skill at all, and the description
|
||||
// is prose a human tunes — the stub says so and generates nothing.
|
||||
if !strings.HasPrefix(body, "---\nname: "+group+"\n") {
|
||||
t.Errorf("%s has no frontmatter naming the group:\n%s", path, firstLines(body, 5))
|
||||
}
|
||||
if !strings.Contains(body, genOpen) || !strings.Contains(body, genClose) {
|
||||
t.Errorf("%s was created without the region markers:\n%s", path, body)
|
||||
}
|
||||
// The block has to say what wrote it: the first thing anybody who finds
|
||||
// it will want to do is edit it in place.
|
||||
if !strings.Contains(body, "kettle gen skills") {
|
||||
t.Errorf("%s does not name the command that regenerates it:\n%s", path, body)
|
||||
}
|
||||
}
|
||||
|
||||
// One command's documentation, end to end: usage line, short, a flag out of
|
||||
// the flag set, and a worked example with its explanation beside it.
|
||||
issues, err := os.ReadFile(filepath.Join(out, "issue", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"## `kettle evict [<id>…]`",
|
||||
"remove closed issues from the local store",
|
||||
"| `--dry-run` | `false` | print what would be removed; touch nothing |",
|
||||
"kettle evict --dry-run",
|
||||
"# print what would go; touch nothing",
|
||||
} {
|
||||
if !strings.Contains(string(issues), want) {
|
||||
t.Errorf("the issue group is missing %q:\n%s", want, issues)
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic to the byte: a regeneration of something that has not
|
||||
// changed must produce no diff at all, or every run of a CI step is a
|
||||
// spurious one.
|
||||
before := readAll(t, out)
|
||||
second := mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
if strings.Contains(second.stdout, "updated") {
|
||||
t.Errorf("a second run rewrote a file:\n%s", second.out())
|
||||
}
|
||||
for path, content := range before {
|
||||
if now := readFile(t, path); now != content {
|
||||
t.Errorf("%s changed on a second run with nothing else changed", path)
|
||||
}
|
||||
}
|
||||
|
||||
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 0 {
|
||||
t.Errorf("--check exited %d on files that were just written:\n%s", r.code, r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// The generator owns a region, not a file. Everything outside the markers is
|
||||
// somebody's prose and comes back exactly as it was.
|
||||
func TestGenLeavesHandWrittenProseAlone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
|
||||
path := filepath.Join(out, "issue", "SKILL.md")
|
||||
raw := readFile(t, path)
|
||||
start := strings.Index(raw, genOpen)
|
||||
end := strings.Index(raw, genClose) + len(genClose)
|
||||
if start < 0 || end < len(genClose) {
|
||||
t.Fatalf("no region in the generated file:\n%s", raw)
|
||||
}
|
||||
|
||||
const above = "\n## Identity: the slug\n\nThe file name is the id, and it never changes.\n\n"
|
||||
const below = "\n\n## Layering rule\n\nThis skill must keep working with the sync skill deleted.\n"
|
||||
// A description a human tuned, in the frontmatter the generator must not
|
||||
// touch: it is the only thing that decides whether the skill loads at all.
|
||||
edited := strings.Replace(raw[:start], "description: TODO", "description: Work with this project's issues as units of work", 1)
|
||||
edited += above + raw[start:end] + below
|
||||
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
after := readFile(t, path)
|
||||
if after != edited {
|
||||
t.Errorf("a no-op regeneration did not return the file byte for byte:\n--- want ---\n%s\n--- got ---\n%s", edited, after)
|
||||
}
|
||||
|
||||
// And the prose survives a regeneration that actually rewrites the block.
|
||||
shortened := strings.Replace(after, genClose, "the block was gutted by hand\n"+genClose, 1)
|
||||
if err := os.WriteFile(path, []byte(shortened), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
restored := readFile(t, path)
|
||||
if restored != edited {
|
||||
t.Error("regenerating the block did not restore it, or did not preserve the prose around it")
|
||||
}
|
||||
if !strings.Contains(restored, "description: Work with this project's issues") {
|
||||
t.Errorf("the hand-tuned description was overwritten:\n%s", firstLines(restored, 5))
|
||||
}
|
||||
if !strings.Contains(restored, above) || !strings.Contains(restored, below) {
|
||||
t.Errorf("hand-written prose outside the markers was lost:\n%s", restored)
|
||||
}
|
||||
}
|
||||
|
||||
// Clobbering somebody's prose because they forgot a marker is the failure this
|
||||
// whole design exists to prevent.
|
||||
func TestGenNeverOverwritesAFileWithoutMarkers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
path := filepath.Join(out, "issue", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const prose = "---\nname: issue\ndescription: hand written, every word of it\n---\n\n# Everything here is somebody's work\n"
|
||||
if err := os.WriteFile(path, []byte(prose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
if got := readFile(t, path); got != prose {
|
||||
t.Fatalf("a file with no markers was rewritten:\n%s", got)
|
||||
}
|
||||
// Left alone silently is how it drifts unnoticed, so it is reported — and
|
||||
// on stderr, where a warning belongs.
|
||||
if !strings.Contains(r.stderr, path) {
|
||||
t.Errorf("the skipped file was not named on stderr:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "without a region") {
|
||||
t.Errorf("the receipt did not account for it:\n%s", r.stdout)
|
||||
}
|
||||
// The other groups still got written — one unmanaged file stops nothing.
|
||||
if _, err := os.Stat(filepath.Join(out, "sync", "SKILL.md")); err != nil {
|
||||
t.Error("one file without markers stopped the whole run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
|
||||
stale := filepath.Join(out, "sync", "SKILL.md")
|
||||
raw := readFile(t, stale)
|
||||
edited := strings.Replace(raw, genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
|
||||
if err := os.WriteFile(stale, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := run(t, dir, "gen", "skills", "--out", out, "--check")
|
||||
if r.code != 1 {
|
||||
t.Fatalf("--check exited %d, want 1 — this is what a hook or a CI step calls:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, stale) {
|
||||
t.Errorf("--check did not say which file is out of date:\n%s", r.out())
|
||||
}
|
||||
// A question about the tree, never an answer written into it.
|
||||
if got := readFile(t, stale); got != edited {
|
||||
t.Error("--check wrote to the file it was asked about")
|
||||
}
|
||||
|
||||
// A file that is not there at all is out of date too, not a nothing.
|
||||
if err := os.Remove(stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 1 {
|
||||
t.Errorf("--check exited %d for a missing file, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
if _, err := os.Stat(stale); err == nil {
|
||||
t.Error("--check created the file it was asked about")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenDryRunWritesNothingAtAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
|
||||
fresh := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
|
||||
if !strings.Contains(fresh.stdout, "would create") {
|
||||
t.Errorf("a dry run said nothing about what it would do:\n%s", fresh.out())
|
||||
}
|
||||
if _, err := os.Stat(out); err == nil {
|
||||
t.Fatal("a dry run created the output directory")
|
||||
}
|
||||
|
||||
// And on an existing tree: the file is described, never touched.
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
path := filepath.Join(out, "issue", "SKILL.md")
|
||||
edited := strings.Replace(readFile(t, path), genClose, "gutted\n"+genClose, 1)
|
||||
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
|
||||
if !strings.Contains(r.stdout, "would update") || !strings.Contains(r.stdout, "nothing was written") {
|
||||
t.Errorf("the dry run did not report the pending change:\n%s", r.out())
|
||||
}
|
||||
if got := readFile(t, path); got != edited {
|
||||
t.Error("a dry run rewrote the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenRefusesAnUnknownTargetAndAMissingOut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if r := run(t, dir, "gen", "skills"); r.code == 0 || !strings.Contains(r.stderr, "--out") {
|
||||
t.Errorf("gen without --out must stop and say so:\n%s", r.out())
|
||||
}
|
||||
if r := run(t, dir, "gen", "agents", "--out", filepath.Join(dir, "x")); r.code == 0 {
|
||||
t.Errorf("an unknown target must be refused:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "x")); err == nil {
|
||||
t.Error("the refused run created its output directory anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// readAll is every file under root, by path, for a byte-for-byte comparison
|
||||
// after a second run.
|
||||
func readAll(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[path] = string(raw)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstLines(s string, n int) string {
|
||||
lines := strings.SplitN(s, "\n", n+1)
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "index",
|
||||
Group: GroupIssue,
|
||||
Short: "rebuild INDEX.md from what is on disk",
|
||||
Long: `A map of the local store, nothing else. The ` + "`origin`" + ` column is the only place
|
||||
the index acknowledges that a tracker exists: ` + "`local`" + ` means the issue has never
|
||||
left this machine, anything else names the tracker it also lives in. Both are
|
||||
ordinary issues here.
|
||||
|
||||
` + "`progress`" + ` counts the body's checkboxes, ticked over total, and is read off the
|
||||
body at build time rather than stored — a second copy of that state in a
|
||||
metadata field would be wrong by the next edit.
|
||||
|
||||
An existing store with nothing in it is a legitimate thing to index and gets an
|
||||
"_empty_" table. A store that is not there is an error, not a directory to
|
||||
create.`,
|
||||
Examples: []Example{
|
||||
{"kettle index", "rebuild the index for this project"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
out := storeFlag(fs)
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return Fail("%v — nothing was created; create an issue with `kettle new`, or pass --out", err)
|
||||
}
|
||||
fmt.Printf("%s — %d issue(s)\n", path, n)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// writeConfig creates or updates .kettle/config.yaml, touching only the
|
||||
// settings it was given.
|
||||
//
|
||||
// Init is idempotent, and that has to include the config: re-running it to add
|
||||
// a repository must not silently drop the login somebody pinned last week.
|
||||
func writeConfig(root, login, repo string, dryRun bool) (string, error) {
|
||||
path := filepath.Join(root, project.Marker, "config.yaml")
|
||||
rel := filepath.Join(project.Marker, "config.yaml")
|
||||
|
||||
cfg, existed, err := config.ReadProjectFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed := !existed
|
||||
if login != "" && cfg.Login != login {
|
||||
cfg.Login, changed = login, true
|
||||
}
|
||||
if repo != "" && cfg.Repo != repo {
|
||||
cfg.Repo, changed = repo, true
|
||||
}
|
||||
if !changed {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
verb := "updated"
|
||||
if !existed {
|
||||
verb = "created"
|
||||
}
|
||||
detail := "no login or repository pinned yet — `kettle init --login … --repo …`"
|
||||
if cfg.Login != "" || cfg.Repo != "" {
|
||||
detail = fmt.Sprintf("login: %s, repo: %s", orNone(cfg.Login), orNone(cfg.Repo))
|
||||
}
|
||||
if dryRun {
|
||||
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
|
||||
}
|
||||
if err := config.SaveProject(path, cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "none"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "init",
|
||||
Group: GroupProject,
|
||||
Short: "make this directory a project that tracks issues",
|
||||
Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from,
|
||||
and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues
|
||||
belong to and which login to reach it under.
|
||||
|
||||
The marker is deliberately something an operator makes, not something inferred
|
||||
from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from
|
||||
one would write issues into whatever it happened to be installed in.
|
||||
|
||||
--login pins a name, never a credential. The tokens live in one file per
|
||||
machine, outside every working tree, managed with ` + "`kettle auth`" + `.
|
||||
|
||||
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
|
||||
an older store in if it finds one (tmp/ or .tea/), writes the config without
|
||||
disturbing settings it was not given, and adds .kettle/ to .gitignore. Each
|
||||
migration is a move, not a copy — two stores is the state the marker exists to
|
||||
prevent — and it refuses to pick a winner when both sides hold a file of the
|
||||
same name.
|
||||
|
||||
Do NOT run this inside a linked worktree. A worktree is the same project on
|
||||
another branch and reaches the store by a hop out to the main checkout; a marker
|
||||
here would give one project two stores, and the directory holding the second one
|
||||
disappears with the branch.`,
|
||||
Examples: []Example{
|
||||
{"kettle init", "initialize the current directory"},
|
||||
{"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"},
|
||||
{"kettle init --at ~/code/x", "initialize somewhere else"},
|
||||
{"kettle init --dry-run", "say what it would do, touch nothing"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
at := fs.String("at", "", "directory to initialize (default: the working directory)")
|
||||
login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)")
|
||||
repo := fs.String("repo", "", "tracker repository, as owner/name")
|
||||
dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing")
|
||||
|
||||
return func(args []string) error {
|
||||
root := *at
|
||||
if root == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root = wd
|
||||
}
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
|
||||
return Fail("%s is not a directory", root)
|
||||
}
|
||||
|
||||
// A second marker inside an existing project gives it a second
|
||||
// store, and the nearer one wins — which is a surprise worth
|
||||
// naming before it happens, not after.
|
||||
if existing := project.Root(root); existing != "" && existing != root {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
|
||||
root, existing)
|
||||
}
|
||||
|
||||
if *repo != "" {
|
||||
if owner, name, ok := strings.Cut(*repo, "/"); !ok || owner == "" || name == "" {
|
||||
return Fail("--repo %q is not owner/name", *repo)
|
||||
}
|
||||
}
|
||||
|
||||
done, err := project.Init(root, *dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
line, err := writeConfig(root, *login, *repo, *dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if line != "" {
|
||||
done = append(done, line)
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if *dryRun {
|
||||
prefix = "would: "
|
||||
}
|
||||
for _, l := range done {
|
||||
fmt.Println(prefix + l)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "labels",
|
||||
Group: GroupSync,
|
||||
Short: "put the canonical type/* and severity/* labels into a repository",
|
||||
Long: `Every ` + "`type/*`" + ` and every ` + "`severity/*`" + ` the domain taxonomy defines, created up
|
||||
front instead of trickling in as a side effect of whichever push first happens
|
||||
to use one. Until a name exists in the repository nobody can filter by it in the
|
||||
web UI, so somebody makes their own — foreign colour, no ` + "`exclusive`" + ` — and the
|
||||
set arrives in pieces over months.
|
||||
|
||||
NO LABEL NAME IS SPELLED OUT HERE. The names come from the domain taxonomy and
|
||||
are painted by the mapping layer, because a hex code is how a tracker paints a
|
||||
chip and not what an issue is. Add a type over in the domain and the next run
|
||||
creates it.
|
||||
|
||||
THE REPOSITORY'S OWN LABELS ARE READ BEFORE ANYTHING IS WRITTEN, and read from
|
||||
the repository, never from a cache — a cache answers "what did we create last
|
||||
time" and the question here is "what does this repository have right now". A
|
||||
name that matches exactly is left alone; a colour or ` + "`exclusive`" + ` that disagrees
|
||||
with the spec is reported, and corrected only under --fix. A name that merely
|
||||
RESEMBLES a canonical one (the same tail, up to case, separator and whatever
|
||||
namespace is in front: ` + "`x`" + `, ` + "`X`" + `, ` + "`kind/x`" + `, ` + "`type: x`" + ` against ` + "`type/x`" + `) is
|
||||
reported with its id and never touched — renaming somebody else's label is a
|
||||
decision, not a step.
|
||||
|
||||
Out of scope by design: ` + "`tech/*`" + ` and ` + "`comp/*`" + `, which are open-ended and are
|
||||
created by push as they come up, and deleting or renaming anything at all. Only
|
||||
repository labels are read; an organization's own labels sit behind a different
|
||||
endpoint and are neither read nor written.
|
||||
|
||||
The issue store is out of scope too, and not incidentally: a label belongs to
|
||||
the repository and not to any issue, so this neither reads the store nor creates
|
||||
it. Request bodies go to the transport's own scratchpad, which is a sibling of
|
||||
the store and never a child.`,
|
||||
Examples: []Example{
|
||||
{"kettle labels --dry-run", "print the plan; not one writing request"},
|
||||
{"kettle labels", "create whatever is missing"},
|
||||
{"kettle labels --fix", "also patch colour / exclusive drift"},
|
||||
{"kettle labels --repo owner/name", "bootstrap another repository"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
dryRun := fs.Bool("dry-run", false, "print the plan; not one writing request")
|
||||
fix := fs.Bool("fix", false, "also patch colour/exclusive on labels that already exist")
|
||||
repo := fs.String("repo", "", "repository to bootstrap, as owner/name (default: this project's)")
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) > 0 {
|
||||
return Fail("labels takes no arguments — the set comes from the taxonomy, not the command line")
|
||||
}
|
||||
// The store root is resolved and then deliberately dropped: this
|
||||
// command must fail the same way as every other sync command when
|
||||
// there is no project, and must touch no issue once there is one.
|
||||
_, client, err := syncStart("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *repo != "" {
|
||||
r, err := wire.ParseRepo(*repo)
|
||||
if err != nil {
|
||||
return Fail("--repo %v", err)
|
||||
}
|
||||
client = client.For(r)
|
||||
}
|
||||
|
||||
specs := mapping.CanonicalLabelSpecs()
|
||||
existing, err := client.ListLabels()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, similar := labelPlan(specs, existing)
|
||||
|
||||
created, fixed, drifted := 0, 0, 0
|
||||
for _, row := range rows {
|
||||
mark := ""
|
||||
if row.spec.Exclusive {
|
||||
mark = " exclusive"
|
||||
}
|
||||
|
||||
if row.got == nil {
|
||||
created++
|
||||
if *dryRun {
|
||||
fmt.Printf("create %-20s %s%s\n", row.spec.Name, row.spec.Color, mark)
|
||||
continue
|
||||
}
|
||||
made, err := client.CreateLabel(row.spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created %-20s id %-5d %s%s\n", row.spec.Name, made.ID, row.spec.Color, mark)
|
||||
continue
|
||||
}
|
||||
if len(row.drift) == 0 {
|
||||
fmt.Printf("present %-20s id %d\n", row.spec.Name, row.got.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
drifted++
|
||||
shown := labelShowDrift(row.drift)
|
||||
if !*fix {
|
||||
fmt.Printf("present %-20s id %-5d drift: %s\n", row.spec.Name, row.got.ID, shown)
|
||||
continue
|
||||
}
|
||||
if *dryRun {
|
||||
fmt.Printf("fix %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
|
||||
continue
|
||||
}
|
||||
// The unchanged name rides along because a server that reads an
|
||||
// absent field as empty would blank it, and the description is
|
||||
// the repository's own: a description somebody rewrote is
|
||||
// theirs, and this run is about colour and exclusivity.
|
||||
patch := row.spec
|
||||
patch.Description = row.got.Description
|
||||
if _, err := client.EditLabel(row.got.ID, patch); err != nil {
|
||||
return err
|
||||
}
|
||||
fixed++
|
||||
fmt.Printf("fixed %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
|
||||
}
|
||||
|
||||
for _, s := range similar {
|
||||
fmt.Fprintf(os.Stderr, "warning: %q (id %d) resembles %s — left alone; rename it by hand or ignore it\n",
|
||||
s.name, s.id, strings.Join(s.hits, ", "))
|
||||
}
|
||||
|
||||
verb := "created"
|
||||
if *dryRun {
|
||||
verb = "to create"
|
||||
}
|
||||
line := fmt.Sprintf("%d canonical label(s): %d %s, %d present",
|
||||
len(rows), created, verb, len(rows)-created)
|
||||
if drifted > 0 {
|
||||
line += fmt.Sprintf(" (%d drifted, %d fixed)", drifted, fixed)
|
||||
}
|
||||
if len(similar) > 0 {
|
||||
line += fmt.Sprintf(", %d similar", len(similar))
|
||||
}
|
||||
fmt.Println(line)
|
||||
if drifted > 0 && !*fix {
|
||||
fmt.Println("drift is shown, not applied — re-run with --fix to patch colour/exclusive")
|
||||
}
|
||||
if *dryRun {
|
||||
fmt.Println("dry-run — nothing was written")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// labelRow is one canonical label, decided before anything is written: what the
|
||||
// taxonomy says it should be, what the repository already has under that exact
|
||||
// name (nil when it has nothing), and where the two disagree.
|
||||
type labelRow struct {
|
||||
spec wire.LabelRequest
|
||||
got *wire.Label
|
||||
drift []labelDiff
|
||||
}
|
||||
|
||||
// labelDiff is one field that disagrees, with both readings, so a receipt can
|
||||
// show the change without the caller re-deriving it.
|
||||
type labelDiff struct{ field, is, want string }
|
||||
|
||||
// labelLookalike is a label of the repository's own that resembles a canonical
|
||||
// name. Reported with its id and never touched.
|
||||
type labelLookalike struct {
|
||||
name string
|
||||
id int64
|
||||
hits []string
|
||||
}
|
||||
|
||||
// labelPlan pairs the canonical set with what the repository holds.
|
||||
//
|
||||
// In taxonomy order, because a bootstrap prints its plan in that order and a map
|
||||
// would shuffle it on every run — two identical runs would look like different
|
||||
// ones.
|
||||
func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []labelLookalike) {
|
||||
byName := make(map[string]*wire.Label, len(existing))
|
||||
for i := range existing {
|
||||
byName[existing[i].Name] = &existing[i]
|
||||
}
|
||||
|
||||
canonical := make(map[string]map[string]bool, len(specs))
|
||||
rows := make([]labelRow, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
canonical[spec.Name] = labelAkin(spec.Name)
|
||||
row := labelRow{spec: spec, got: byName[spec.Name]}
|
||||
if row.got != nil {
|
||||
row.drift = labelDrift(spec, row.got)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
var similar []labelLookalike
|
||||
for i := range existing {
|
||||
l := &existing[i]
|
||||
if _, exact := canonical[l.Name]; exact {
|
||||
continue
|
||||
}
|
||||
mine := labelAkin(l.Name)
|
||||
var hits []string
|
||||
for _, spec := range specs {
|
||||
if labelIntersects(canonical[spec.Name], mine) {
|
||||
hits = append(hits, spec.Name)
|
||||
}
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
similar = append(similar, labelLookalike{name: l.Name, id: l.ID, hits: hits})
|
||||
}
|
||||
}
|
||||
return rows, similar
|
||||
}
|
||||
|
||||
// labelDrift is where an existing label disagrees with the spec.
|
||||
//
|
||||
// Colour and `exclusive` only. A description somebody rewrote is theirs, and the
|
||||
// name matched exactly or this row would not exist.
|
||||
func labelDrift(spec wire.LabelRequest, got *wire.Label) []labelDiff {
|
||||
var out []labelDiff
|
||||
if labelHex(got.Color) != labelHex(spec.Color) {
|
||||
out = append(out, labelDiff{"color", labelHex(got.Color), labelHex(spec.Color)})
|
||||
}
|
||||
if got.Exclusive != spec.Exclusive {
|
||||
out = append(out, labelDiff{"exclusive",
|
||||
fmt.Sprintf("%t", got.Exclusive), fmt.Sprintf("%t", spec.Exclusive)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// labelHex normalizes a colour for comparison. Gitea reports them bare
|
||||
// (`ee0701`) and the mapping layer writes them with a `#`; same colour, so a
|
||||
// comparison has to strip before it compares.
|
||||
func labelHex(v string) string { return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), "#")) }
|
||||
|
||||
var labelWords = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// labelAkin is the comparison keys for a label name: its tail, and the whole
|
||||
// name squashed.
|
||||
//
|
||||
// Case, separators and the namespace in front are noise — what a person meant is
|
||||
// the tail. `x`, `X` and `kind/x` all reduce to the same tail as `type/x`, and
|
||||
// `severity: x y` to the same squashed form as `severity/xy`. Two names resemble
|
||||
// each other when these sets intersect.
|
||||
func labelAkin(name string) map[string]bool {
|
||||
var parts []string
|
||||
for _, p := range labelWords.Split(strings.ToLower(name), -1) {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{parts[len(parts)-1]: true, strings.Join(parts, ""): true}
|
||||
}
|
||||
|
||||
func labelIntersects(a, b map[string]bool) bool {
|
||||
for k := range a {
|
||||
if b[k] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func labelShowDrift(drift []labelDiff) string {
|
||||
var out []string
|
||||
for _, d := range drift {
|
||||
out = append(out, fmt.Sprintf("%s %s -> %s", d.field, d.is, d.want))
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "new",
|
||||
Group: GroupIssue,
|
||||
Short: "create a local issue from its type template",
|
||||
Long: `The issue is real the moment this writes the file. Nothing is pending, nothing
|
||||
is a draft awaiting a tracker: ` + "`origin: local`" + ` is a complete state and pushing it
|
||||
later is optional.
|
||||
|
||||
While it says local, this file is the ONLY copy of the work — the store, not a
|
||||
cache of anything. That is what a push changes: it hands the issue to the
|
||||
tracker and removes the file.
|
||||
|
||||
Writes .tea/issues/<slug>.md prefilled with the type's template, prints the
|
||||
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
|
||||
` + "`kettle check <id>`" + `.
|
||||
|
||||
Body prose is Russian, section headers and the title are English.`,
|
||||
Examples: []Example{
|
||||
{`kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick`,
|
||||
"a task with two free-form labels"},
|
||||
{`kettle new --type bug --title "Fix tea-guard crash on empty settings" --depends wire-sqlc-appclick --milestone v0.2`,
|
||||
"a bug that is blocked by another issue"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
typ := fs.String("type", "", "issue type, one of: "+strings.Join(issue.TypeNames(), ", ")+" (becomes the exclusive type/* label)")
|
||||
title := fs.String("title", "", "English, imperative, no type prefix")
|
||||
id := fs.String("id", "", "slug (default: derived from the title)")
|
||||
severity := fs.String("severity", "", "severity/* label, one of: "+strings.Join(issue.Severities, ", "))
|
||||
milestone := fs.String("milestone", "", "milestone title")
|
||||
var labels, assignees, depends stringList
|
||||
fs.Var(&labels, "label", "extra label, e.g. tech/sql; repeat")
|
||||
fs.Var(&assignees, "assignee", "assignee login; repeat")
|
||||
fs.Var(&depends, "depends", "id this issue depends on; repeat")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if *typ == "" || *title == "" {
|
||||
return Fail("--type and --title are both required")
|
||||
}
|
||||
if !issue.KnownType(*typ) {
|
||||
return Fail("unknown --type %q — known: %s", *typ, strings.Join(issue.TypeNames(), ", "))
|
||||
}
|
||||
if *severity != "" && !issue.KnownSeverity(*severity) {
|
||||
return Fail("unknown --severity %q — known: %s", *severity, strings.Join(issue.Severities, ", "))
|
||||
}
|
||||
// Before anything reads the store path. There is no store to be
|
||||
// second-guessed about when there is no project.
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labelSet := []string{"type/" + *typ}
|
||||
if *severity != "" {
|
||||
labelSet = append(labelSet, "severity/"+*severity)
|
||||
}
|
||||
for _, l := range labels {
|
||||
if !contains(labelSet, l) {
|
||||
labelSet = append(labelSet, l)
|
||||
}
|
||||
}
|
||||
|
||||
slug := *id
|
||||
if slug == "" {
|
||||
if slug, err = issue.UniqueID(root, issue.Slugify(*title, 0), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !issue.IsSlug(slug) {
|
||||
return Fail("--id %q is not a slug (lowercase, digits, single dashes)", slug)
|
||||
}
|
||||
if _, err := os.Stat(issue.PathOf(root, slug)); err == nil {
|
||||
return Fail("%s already exists", issue.PathOf(root, slug))
|
||||
}
|
||||
|
||||
known := map[string]bool{}
|
||||
for _, k := range issue.AllIDs(root) {
|
||||
known[k] = true
|
||||
}
|
||||
for _, d := range depends {
|
||||
if !known[d] {
|
||||
fmt.Fprintf(os.Stderr, "warning: depends on %q, which is not in the store yet\n", d)
|
||||
}
|
||||
}
|
||||
|
||||
i := &issue.Issue{
|
||||
ID: slug,
|
||||
Title: *title,
|
||||
Body: issue.Template(*typ, depends),
|
||||
State: "open",
|
||||
Labels: labelSet,
|
||||
Assignees: assignees,
|
||||
Milestone: *milestone,
|
||||
Depends: depends,
|
||||
Origin: issue.Local,
|
||||
}
|
||||
|
||||
// The first issue in a fresh checkout has to create the store,
|
||||
// but it says so — and it says where, because the path is
|
||||
// absolute.
|
||||
created, err := issue.CreateStore(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
abs, _ := filepath.Abs(root)
|
||||
fmt.Fprintf(os.Stderr, "created store %s\n", abs)
|
||||
}
|
||||
|
||||
path, err := issue.Save(root, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := issue.BuildIndex(root); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s [type/%s] %s\n", path, *typ, *title)
|
||||
fmt.Printf("fill the sections, then: kettle check %s\n", slug)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "pull",
|
||||
Group: GroupSync,
|
||||
Args: "[<key>…]",
|
||||
Short: "fetch issues from the tracker into the local store",
|
||||
Long: `THIS IS HOW A PUSHED ISSUE COMES BACK. ` + "`kettle push`" + ` deletes the local file the
|
||||
moment the tracker confirms the write, so a pull is not a refresh of a copy you
|
||||
kept — it is how the copy comes to exist at all.
|
||||
|
||||
It lands under the SAME slug it had before, after a rename in the web UI and on
|
||||
a machine that has never seen the issue. Three sources answer "what is this
|
||||
issue called here", in this order:
|
||||
|
||||
.remote.json the number -> slug ledger; the only one that knows what
|
||||
is on disk right now, so it wins
|
||||
<!-- kettle:id … --> the marker in the tracker-side body; it survives a lost
|
||||
ledger, a fresh clone, another machine, and a retitling
|
||||
the title slugified — where an issue filed in the web UI gets its
|
||||
first local name
|
||||
|
||||
A marker is taken at its word only when the slug is free; a name already in use
|
||||
is a collision, not an identity, and is uniquified rather than allowed to
|
||||
overwrite somebody else's issue. The marker itself is stripped out of what lands
|
||||
on disk.
|
||||
|
||||
TWO WAYS TO NAME WHAT TO PULL, and they are not the same operation:
|
||||
|
||||
kettle pull 42 #43 owner/repo#44 by key — an ADDRESS
|
||||
kettle pull --milestone v0.2 by filter — a QUERY
|
||||
|
||||
A key fetches an issue in ANY state, because a number is an address and not a
|
||||
question about state. Only filter mode leaves closed issues out — a closed issue
|
||||
is not a unit of work — and only ` + "`--state closed`" + ` puts one in the store. An issue
|
||||
already on disk is refreshed either way, so a local copy learns it was closed
|
||||
instead of staying open forever, and the count that stayed out goes to stderr.
|
||||
|
||||
` + "`--limit`" + ` IS ON THE WRITE, NOT ON THE SELECTION. It counts the issues this run puts
|
||||
in the store and never the closed ones it enumerated and threw away, so pages
|
||||
keep coming until the budget is full — and stop the moment it is. A filter that
|
||||
matches almost only closed issues ends in a warning and a short answer rather
|
||||
than a walk of the whole tracker.
|
||||
|
||||
A PULL RETURNS THE UNIT OF WORK, NOT ONE ROW OF IT. ` + "`depends:`" + ` is filled from the
|
||||
tracker's own dependency graph and every blocker comes down with it, recursively,
|
||||
to --depth. What that costs, stated rather than hidden: one request per issue
|
||||
that lands in the store, plus one per blocker the selection did not already
|
||||
carry. ` + "`--no-deps`" + ` is the way back to one request, and narrows the answer to the
|
||||
one issue you asked for. Dependencies are outside --limit: a blocker is followed
|
||||
because a stored issue named it, not because the filter selected it, so a
|
||||
filtered pull can leave more files behind than its limit — including one from
|
||||
another milestone. The one blocker that does not land is a closed one.
|
||||
|
||||
PULLING OVERWRITES THE BODY: a fetch, not a merge. Local edits you have not
|
||||
pushed are lost, with exactly one exception — checkbox state. A tick is monotone,
|
||||
so a ` + "`[x]`" + ` on either side wins for any item whose text matches; unticking is not,
|
||||
so untick locally and push. ` + "`--cached`" + ` skips an issue before any of that.
|
||||
|
||||
Comments ride along: the thread lands beside the issue in <id>.comments.md. It
|
||||
costs no request when the payload says there are none, and a file left over from
|
||||
an earlier pull is deleted — so no file means "no comments", never "not asked
|
||||
for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
||||
Examples: []Example{
|
||||
{"kettle pull 42", "the issue and everything blocking it, in any state"},
|
||||
{"kettle pull 42 --no-deps", "just that one issue — one request"},
|
||||
{"kettle pull owner/repo#42", "an issue in another repository"},
|
||||
{"kettle pull --milestone v0.2 --limit 20", "20 open issues from a milestone, blockers included"},
|
||||
{"kettle pull --label type/bug --state all", "every bug; the closed ones are enumerated, not stored"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
milestone := fs.String("milestone", "", "pull a whole milestone (id or title)")
|
||||
var labels stringList
|
||||
fs.Var(&labels, "label", "filter by label; repeat for AND")
|
||||
// Both spellings, because both are what somebody has in hand: `-q` is
|
||||
// what a person types and `--query` is what a script reads back.
|
||||
var query string
|
||||
fs.StringVar(&query, "q", "", "search text in title and body")
|
||||
fs.StringVar(&query, "query", "", "the long spelling of -q")
|
||||
state := fs.String("state", "open", "filter mode only: open, closed or all")
|
||||
limit := fs.Int("limit", 100, "filter mode: how many issues to STORE, not to enumerate")
|
||||
noDeps := fs.Bool("no-deps", false, "do not fill depends: and do not follow blockers")
|
||||
depth := fs.Int("depth", 3, "how deep to follow blockers")
|
||||
cached := fs.Bool("cached", false, "skip issues already on disk instead of refetching")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
filtered := *milestone != "" || len(labels) > 0 || query != ""
|
||||
switch {
|
||||
case len(args) > 0 && filtered:
|
||||
return Fail("pass issue keys OR filters, not both")
|
||||
case len(args) == 0 && !filtered:
|
||||
return Fail("nothing to pull: pass an issue key, or --milestone / --label / -q")
|
||||
case !contains([]string{"open", "closed", "all"}, *state):
|
||||
return Fail("--state %q must be open, closed or all", *state)
|
||||
case *limit < 1:
|
||||
return Fail("--limit must be 1 or more, got %d", *limit)
|
||||
case *depth < 0:
|
||||
return Fail("--depth must be 0 or more, got %d", *depth)
|
||||
}
|
||||
|
||||
// Keys are parsed before anything is opened: a typo in a key is
|
||||
// not a network problem and must not be reported as one.
|
||||
keys, named, err := pullKeys(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, client, err := syncStart(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A key may name its own repository; the project's is the
|
||||
// fallback, never an override.
|
||||
if !named.Zero() {
|
||||
client = client.For(named)
|
||||
}
|
||||
repo := client.Repo()
|
||||
|
||||
// A first pull into a fresh checkout has to create the store, and
|
||||
// it says so — with an absolute path, so it cannot be a stray
|
||||
// working directory.
|
||||
created, err := issue.CreateStore(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
abs, _ := filepath.Abs(root)
|
||||
fmt.Fprintf(os.Stderr, "created store %s\n", abs)
|
||||
}
|
||||
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger := loadLedgerOrFold(root, issues)
|
||||
namer := &pullNamer{root: root, repo: repo, ledger: ledger, taken: map[string]bool{}}
|
||||
for id := range issues {
|
||||
namer.taken[id] = true
|
||||
}
|
||||
// What the ledger already knows, so a `#N` in a body resolves to
|
||||
// a slug this run never fetched.
|
||||
numberOf := map[int]string{}
|
||||
for raw, slug := range ledger {
|
||||
if k, err := wire.ParseKey(raw); err == nil && k.Repo == repo {
|
||||
numberOf[k.Number] = slug
|
||||
}
|
||||
}
|
||||
|
||||
// A closed issue is not a unit of work: filter mode enumerates it
|
||||
// and keeps it out of the store unless the operator named the
|
||||
// state. A key is an address, not a bulk read, so key mode is
|
||||
// exempt.
|
||||
dropClosed := filtered && *state != "closed"
|
||||
|
||||
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
|
||||
State: *state, Labels: labels, Query: query, Milestone: *milestone,
|
||||
Limit: *limit,
|
||||
Keep: func(p *wire.Issue) bool {
|
||||
return pullLandsInStore(p, dropClosed, namer)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var written, skipped []string
|
||||
var dropped []int
|
||||
type unresolved struct {
|
||||
id string
|
||||
numbers []int
|
||||
}
|
||||
var pending []unresolved
|
||||
|
||||
seen := map[int]bool{}
|
||||
for _, t := range queue {
|
||||
seen[t.payload.Number] = true
|
||||
}
|
||||
|
||||
for len(queue) > 0 {
|
||||
task := queue[0]
|
||||
queue = queue[1:]
|
||||
p := task.payload
|
||||
id, err := namer.idFor(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored := pullStored(root, id)
|
||||
|
||||
// Closed and not already ours: nothing is written and nothing
|
||||
// is asked of the server for it — not its comments, not its
|
||||
// links, and its blockers are not followed. The slug stays
|
||||
// unclaimed too, so no other issue ends up pointing
|
||||
// `depends:` at a file that is not there.
|
||||
if dropClosed && p.State == "closed" && !stored {
|
||||
dropped = append(dropped, p.Number)
|
||||
continue
|
||||
}
|
||||
|
||||
namer.taken[id] = true
|
||||
numberOf[p.Number] = id
|
||||
|
||||
// The native links, fetched ONCE for the two things they are
|
||||
// for: filling this issue's `depends:` and telling the walk
|
||||
// where to go next. One request per issue that lands in the
|
||||
// store, and only one.
|
||||
var blockers []int
|
||||
if !*noDeps {
|
||||
if blockers, err = pullBlockers(client, p.Number, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if *cached && stored {
|
||||
skipped = append(skipped, id) // body and thread unread; only the links cost
|
||||
} else {
|
||||
// The copy already on disk, as it was when this run
|
||||
// started. It contributes its ticked checkboxes and
|
||||
// nothing else.
|
||||
local := ""
|
||||
if prev := issues[id]; prev != nil {
|
||||
local = prev.Body
|
||||
}
|
||||
next, missing := mapping.FromPayload(p, id, repo, mapping.PayloadOptions{
|
||||
IDForNumber: numberOf,
|
||||
ExtraNumbers: blockers,
|
||||
// The clock is the caller's: mapping is a pure layer
|
||||
// and a package with a clock in it is not one.
|
||||
Synced: time.Now().UTC().Format(time.RFC3339),
|
||||
LocalBody: local,
|
||||
})
|
||||
if _, err := issue.Save(root, next); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := pullSyncComments(client, root, id, p.Number, p.Comments); err != nil {
|
||||
return err
|
||||
}
|
||||
ledger.Set(wire.Key{Repo: repo, Number: p.Number}, id)
|
||||
written = append(written, id)
|
||||
pending = append(pending, unresolved{id, missing})
|
||||
}
|
||||
|
||||
if *noDeps || task.depth >= *depth {
|
||||
continue
|
||||
}
|
||||
for _, n := range append(mapping.NumbersInBody(p.Body), blockers...) {
|
||||
if seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
child, err := client.GetIssue(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queue = append(queue, pullTask{payload: child, depth: task.depth + 1})
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is dropped in silence.
|
||||
if len(dropped) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%d closed issue(s) enumerated, not stored"+
|
||||
" (--state closed to pull them)\n", len(dropped))
|
||||
}
|
||||
|
||||
// Second pass: a `#N` that named an issue this run had not written
|
||||
// yet. The first write could not resolve it to a slug; by now the
|
||||
// file it names is on disk.
|
||||
for _, u := range pending {
|
||||
var newly []string
|
||||
for _, n := range u.numbers {
|
||||
if slug := numberOf[n]; slug != "" && slug != u.id {
|
||||
newly = append(newly, slug)
|
||||
}
|
||||
}
|
||||
if len(newly) == 0 {
|
||||
continue
|
||||
}
|
||||
i, err := issue.Load(root, u.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, slug := range newly {
|
||||
if !contains(i.Depends, slug) {
|
||||
i.Depends = append(i.Depends, slug)
|
||||
}
|
||||
}
|
||||
if _, err := issue.Save(root, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
indexPath, _, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pullReceipt(root, written, skipped, indexPath)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// pullTask is one issue to walk, and how far from a seed it was found.
|
||||
type pullTask struct {
|
||||
payload *wire.Issue
|
||||
depth int
|
||||
}
|
||||
|
||||
// pullKeys parses the positional arguments and the one repository they may name.
|
||||
//
|
||||
// All of them or none: a run addresses one repository, because the client, the
|
||||
// ledger keys and the `gitea:` field all have to agree about which one.
|
||||
func pullKeys(args []string) ([]wire.Key, wire.Repo, error) {
|
||||
var keys []wire.Key
|
||||
var named wire.Repo
|
||||
for _, a := range args {
|
||||
k, err := wire.ParseKey(a)
|
||||
if err != nil {
|
||||
return nil, wire.Repo{}, err
|
||||
}
|
||||
if !k.Repo.Zero() {
|
||||
if !named.Zero() && named != k.Repo {
|
||||
return nil, wire.Repo{}, Fail("all keys must name one repository, got %s and %s", named, k.Repo)
|
||||
}
|
||||
named = k.Repo
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, named, nil
|
||||
}
|
||||
|
||||
// pullSeed is what the walk starts from: the issues a key addresses, or the ones
|
||||
// a filter selected.
|
||||
func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilter) ([]pullTask, error) {
|
||||
if !filtered {
|
||||
out := make([]pullTask, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
p, err := c.GetIssue(k.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pullTask{payload: p})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
listing, err := c.ListIssues(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(listing.Issues) == 0 {
|
||||
return nil, Fail("no issues match that filter")
|
||||
}
|
||||
if listing.Warning != "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s\n", listing.Warning)
|
||||
}
|
||||
|
||||
var what []string
|
||||
if listing.Milestone != "" {
|
||||
what = append(what, "milestone "+listing.Milestone)
|
||||
}
|
||||
for _, l := range f.Labels {
|
||||
what = append(what, "label "+l)
|
||||
}
|
||||
if f.Query != "" {
|
||||
what = append(what, fmt.Sprintf("q=%q", f.Query))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%d issue(s) match %s (%s)\n",
|
||||
len(listing.Issues), strings.Join(what, " + "), f.State)
|
||||
|
||||
out := make([]pullTask, 0, len(listing.Issues))
|
||||
for i := range listing.Issues {
|
||||
out = append(out, pullTask{payload: &listing.Issues[i]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pullLandsInStore is the --limit predicate: would this payload leave a file in
|
||||
// the store?
|
||||
//
|
||||
// It has to be the same test the walk applies, or the budget is spent on issues
|
||||
// that never land — which is the bug it exists to prevent. A closed issue counts
|
||||
// only when the store already has it (it is refreshed, and that is a write);
|
||||
// anything else counts, including one --cached will skip, because a skipped
|
||||
// issue is still an issue the store holds when the run ends.
|
||||
func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
|
||||
if !dropClosed || p.State != "closed" {
|
||||
return true
|
||||
}
|
||||
id, err := namer.idFor(p)
|
||||
if err != nil {
|
||||
// An id that cannot be allocated is the walk's failure to report, not a
|
||||
// reason to spend the page budget differently.
|
||||
return true
|
||||
}
|
||||
return pullStored(namer.root, id)
|
||||
}
|
||||
|
||||
// pullBlockers is the numbers of the issues that block this one, in this
|
||||
// repository.
|
||||
//
|
||||
// A blocker in ANOTHER repository is dropped here, and deliberately: everything
|
||||
// downstream — `depends:`, the number -> slug ledger, the walk's own GETs — reads
|
||||
// a bare number against the repository being pulled, so a foreign number would
|
||||
// either resolve to the wrong issue or invent an edge. The body still names it,
|
||||
// so nothing is lost.
|
||||
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
|
||||
deps, err := c.Dependencies(number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []int
|
||||
for i := range deps {
|
||||
if k := deps[i].KeyIn(repo); k.Repo == repo {
|
||||
out = append(out, k.Number)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pullSyncComments brings <id>.comments.md in line with the tracker and returns
|
||||
// its path, or "" when the issue has no thread.
|
||||
//
|
||||
// count is the payload's own comment count, so an issue with none costs no
|
||||
// request. A file from an earlier pull is removed when the thread is empty: the
|
||||
// absence of the file is the answer, not a gap in what was asked for.
|
||||
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
|
||||
path := commentsSidecarPath(root, id)
|
||||
var thread []wire.Comment
|
||||
if count > 0 {
|
||||
var err error
|
||||
if thread, err = c.ListComments(number); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if len(thread) > 0 {
|
||||
if err := os.WriteFile(path, []byte(mapping.RenderComments(thread)), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// pullReceipt is the only thing that lands in a reader's context: one line per
|
||||
// issue, the raw payload nowhere.
|
||||
func pullReceipt(root string, written, skipped []string, indexPath string) error {
|
||||
cached := map[string]bool{}
|
||||
all := map[string]bool{}
|
||||
for _, id := range written {
|
||||
all[id] = true
|
||||
}
|
||||
for _, id := range skipped {
|
||||
cached[id], all[id] = true, true
|
||||
}
|
||||
ids := make([]string, 0, len(all))
|
||||
for id := range all {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
graph := false
|
||||
for _, id := range ids {
|
||||
i, err := issue.Load(root, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
graph = graph || len(i.Depends) > 0
|
||||
note := ""
|
||||
if cached[id] {
|
||||
note = " (cached)"
|
||||
}
|
||||
if path := commentsSidecarPath(root, id); pullIsFile(path) {
|
||||
n := i.Extra[mapping.CommentsKey]
|
||||
if n == "" {
|
||||
n = "?"
|
||||
}
|
||||
note += fmt.Sprintf(" +%s comments: %s", n, path)
|
||||
}
|
||||
labels := strings.Join(i.Labels, ", ")
|
||||
if labels == "" {
|
||||
labels = "no labels"
|
||||
}
|
||||
fmt.Printf("%s [%s] %s — %s %s%s\n",
|
||||
id, labels, i.Title, i.State, issue.PathOf(root, id), note)
|
||||
}
|
||||
fmt.Printf("index: %s\n", indexPath)
|
||||
// Worth printing when there is something to draw, not on every run that
|
||||
// could have drawn something.
|
||||
if graph {
|
||||
fmt.Println("graph: run `kettle tree` (offline) to draw it")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// naming, and the files the sync layer parks beside an issue
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// pullNamer answers "what is this remote issue called here", and remembers what
|
||||
// it has already handed out so one run cannot name two issues the same thing.
|
||||
type pullNamer struct {
|
||||
root string
|
||||
repo wire.Repo
|
||||
ledger gitea.RemoteMap
|
||||
// taken is every slug the store holds plus every one this run has claimed.
|
||||
taken map[string]bool
|
||||
}
|
||||
|
||||
// idFor is the slug this remote issue belongs under. Three sources, in order —
|
||||
// see the command's own documentation for why that order and not another.
|
||||
func (n *pullNamer) idFor(p *wire.Issue) (string, error) {
|
||||
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: p.Number}); got != "" {
|
||||
return got, nil
|
||||
}
|
||||
marked := mapping.IDInBody(p.Body)
|
||||
// A marker is an identity only while the name is free. A file of that name
|
||||
// already in the store, or a ledger entry holding it under another number,
|
||||
// makes it a collision — and overwriting somebody else's issue is worse than
|
||||
// allocating a suffix.
|
||||
if marked != "" && !n.taken[marked] && !n.ledgerHolds(marked) {
|
||||
return marked, nil
|
||||
}
|
||||
base := marked
|
||||
if base == "" {
|
||||
base = issue.Slugify(p.Title, 0)
|
||||
}
|
||||
taken := make([]string, 0, len(n.taken))
|
||||
for id := range n.taken {
|
||||
taken = append(taken, id)
|
||||
}
|
||||
return issue.UniqueID(n.root, base, taken)
|
||||
}
|
||||
|
||||
func (n *pullNamer) ledgerHolds(slug string) bool {
|
||||
for _, s := range n.ledger {
|
||||
if s == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadLedgerOrFold is the number -> slug ledger, with the `gitea:` fields still
|
||||
// on disk folded in when there is no ledger to read.
|
||||
//
|
||||
// A MERGE and never a replacement, which is why the fold only happens when the
|
||||
// file is missing or empty: push deletes the file it has just sent, so the store
|
||||
// is a SUBSET of what the ledger knows and a rebuild from the files alone would
|
||||
// throw away every entry it cannot see. What a fold cannot recover — a
|
||||
// pushed-and-dropped issue whose entry was also lost — is not lost either: the
|
||||
// next pull of that number reads the slug off the marker in the body and writes
|
||||
// the entry back.
|
||||
func loadLedgerOrFold(root string, issues map[string]*issue.Issue) gitea.RemoteMap {
|
||||
m := gitea.LoadRemoteMap(root)
|
||||
if len(m) > 0 {
|
||||
return m
|
||||
}
|
||||
for id, i := range issues {
|
||||
if k, ok := mapping.RemoteKeyOf(i); ok {
|
||||
m.Set(k, id)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// pullStored reports whether the store already holds this issue.
|
||||
func pullStored(root, id string) bool { return pullIsFile(issue.PathOf(root, id)) }
|
||||
|
||||
func pullIsFile(path string) bool {
|
||||
fi, err := os.Stat(path)
|
||||
return err == nil && !fi.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "push",
|
||||
Group: GroupSync,
|
||||
Args: "[<id>…]",
|
||||
Short: "send local issues to the tracker; the local copy goes with them",
|
||||
Long: `A SUCCESSFUL PUSH DELETES THE LOCAL FILE — <id>.md and every sidecar under that
|
||||
slug — and prints the number and the URL the issue now lives at. Once the tracker
|
||||
has the issue, the tracker IS the issue: what is left in the store is what has
|
||||
not left this machine. Get it back with ` + "`kettle pull <n>`" + `, which returns it under
|
||||
the same slug, because the slug travelled up in the body as <!-- kettle:id … -->
|
||||
and was recorded in the number -> slug ledger.
|
||||
|
||||
ONE RULE, NO EXCEPTION: --update deletes as well. A PATCH is a push, and an issue
|
||||
that has just been sent is no more local than one that was just created. Two
|
||||
rules would put back exactly the question this removes — "is my copy the fresh
|
||||
one?".
|
||||
|
||||
THE DELETION IS THE LAST THING THAT HAPPENS TO AN ISSUE, and only after all
|
||||
three of:
|
||||
|
||||
1. the call came back without an error and with a 2xx,
|
||||
2. the answer carries a plausible number — on --update the very number that
|
||||
was PATCHed, and
|
||||
3. the ledger has been written with number -> slug.
|
||||
|
||||
Network down, non-2xx, an answer that does not confirm the write: the file stays
|
||||
and the run stops. Nothing removes a file it has not just watched the tracker
|
||||
accept, and nothing removes a file for an issue it did not send — ` + "`origin: local`" + `
|
||||
work that has never been pushed is never touched by any of this. Get the ordering
|
||||
wrong and a slug is lost at exactly the moment the local copy stops being the
|
||||
record, which is why the ledger is written before anything is deleted and not
|
||||
after.
|
||||
|
||||
Every issue is validated against the canonical format first, offline and before
|
||||
a socket is opened. --force posts anyway; say why when you use it.
|
||||
|
||||
DEPENDENCIES GO FIRST, in topological order, so a blocker has its number before
|
||||
the issue that names it. Every ` + "`depends:`" + ` entry that has a number becomes a NATIVE
|
||||
tracker link — the same /dependencies a pull reads back, so the tracker shows the
|
||||
blocking panel and refuses to close a blocked issue first. A link that is already
|
||||
there is skipped, not re-POSTed, which is what makes a repeat push a no-op. A
|
||||
dependency that is still local-only has no number and becomes no link: it is
|
||||
reported, never silently dropped.
|
||||
|
||||
REMOVING a link is out of scope — push only ever adds. A dependency deleted from
|
||||
` + "`depends:`" + ` leaves its tracker link standing; unlink it in the web UI.
|
||||
|
||||
The ` + "`## Depends on`" + ` prose is never touched: slugs stay slugs and are not rewritten
|
||||
to #N, so a pull -> push round trip is byte for byte.
|
||||
|
||||
Labels the repository is missing are created with the canonical colour and, for
|
||||
type/* and severity/*, exclusive: true. ` + "`branch:`" + ` carries the tracker's ` + "`ref`" + `: an
|
||||
empty one is filled with the current git branch and an already-set one is sent as
|
||||
written. Detached HEAD or no repository at all is not an error — no ref is sent
|
||||
and a warning says so.`,
|
||||
Examples: []Example{
|
||||
{"kettle push", "every issue the tracker does not have yet, blockers first"},
|
||||
{"kettle push wire-sqlc-appclick", "one issue"},
|
||||
{"kettle push --update wire-sqlc-appclick", "PATCH one that is already there — the file still goes"},
|
||||
{"kettle push --dry-run", "validate and print the plan; no network, nothing deleted"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
update := fs.Bool("update", false, "PATCH issues that already carry a gitea: field")
|
||||
dryRun := fs.Bool("dry-run", false, "validate and print the plan; no network, nothing deleted")
|
||||
force := fs.Bool("force", false, "push despite format violations")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
// A dry run touches no network, so it must not need a credential
|
||||
// to say what it would do. The real run goes through
|
||||
// syncStartExisting below, which resolves the store before it
|
||||
// builds a client — a missing store reported as a network
|
||||
// problem sends the operator to the wrong place.
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issue.StoreError(root); err != nil {
|
||||
return Fail("%s — create an issue with `kettle new` first", err)
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chosen, err := pushSelect(issues, args, *update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The domain's own check, offline, before anything is sent.
|
||||
known := map[string]bool{}
|
||||
for id := range issues {
|
||||
known[id] = true
|
||||
}
|
||||
blocked := false
|
||||
for _, id := range chosen {
|
||||
errs, warns := issue.Validate(issues[id], known)
|
||||
for _, w := range warns {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %s\n", id, w)
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n", id, e)
|
||||
}
|
||||
blocked = blocked || len(errs) > 0
|
||||
}
|
||||
if blocked && !*force {
|
||||
return Fail("format violations (see above); --force overrides")
|
||||
}
|
||||
|
||||
// Dependencies first, so a blocker has its number by the time the
|
||||
// issue that names it is sent. A cycle is reported and ordered
|
||||
// around rather than refused: it is a data problem, not a reason
|
||||
// to send nothing.
|
||||
edges := map[string][]string{}
|
||||
for _, id := range chosen {
|
||||
var deps []string
|
||||
for _, d := range issues[id].Depends {
|
||||
if _, ok := issues[d]; ok {
|
||||
deps = append(deps, d)
|
||||
}
|
||||
}
|
||||
edges[id] = deps
|
||||
}
|
||||
pushing := map[string]bool{}
|
||||
for _, id := range chosen {
|
||||
pushing[id] = true
|
||||
}
|
||||
var order []string
|
||||
for _, id := range issue.TopoOrder(chosen, edges) {
|
||||
if pushing[id] {
|
||||
order = append(order, id)
|
||||
}
|
||||
}
|
||||
for _, c := range issue.FindCycles(edges) {
|
||||
fmt.Fprintf(os.Stderr, "warning: dependency cycle: %s\n", strings.Join(c, " -> "))
|
||||
}
|
||||
|
||||
// Only an EMPTY branch: one written by hand is the author's
|
||||
// decision and a push does not argue with it. The value is set on
|
||||
// the in-memory issue only — the file it came from is about to be
|
||||
// deleted, and the branch comes back with the next pull.
|
||||
var blank []string
|
||||
for _, id := range order {
|
||||
if strings.TrimSpace(issues[id].Extra[mapping.BranchKey]) == "" {
|
||||
blank = append(blank, id)
|
||||
}
|
||||
}
|
||||
if len(blank) > 0 {
|
||||
if branch := pushGitBranch(); branch != "" {
|
||||
for _, id := range blank {
|
||||
if issues[id].Extra == nil {
|
||||
issues[id].Extra = map[string]string{}
|
||||
}
|
||||
issues[id].Extra[mapping.BranchKey] = branch
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: no current git branch (detached HEAD, or "+
|
||||
"outside a git repository) — no `ref` on: %s\n", strings.Join(blank, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
// Not one request is made here: everything below is read off
|
||||
// the store and the ledger, which costs nothing.
|
||||
pushPlan(root, issues, order, pushing, *update)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repo := client.Repo()
|
||||
|
||||
var wanted []string
|
||||
for _, id := range order {
|
||||
for _, l := range issues[id].Labels {
|
||||
if !contains(wanted, l) {
|
||||
wanted = append(wanted, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(wanted)
|
||||
labelIDs, err := pushLabelIDs(client, wanted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
milestones := map[string]*int64{}
|
||||
ledger := loadLedgerOrFold(root, issues)
|
||||
keyOf := pushLedgerKeys(ledger, repo)
|
||||
|
||||
for _, id := range order {
|
||||
i := issues[id]
|
||||
|
||||
// Local-only means "this machine has never sent it": no
|
||||
// `gitea:` on the file AND no entry in the ledger. A blocker
|
||||
// whose file an earlier push already dropped is in the ledger
|
||||
// and is not one of these.
|
||||
var unsynced []string
|
||||
for _, d := range i.Depends {
|
||||
dep, onDisk := issues[d]
|
||||
if !onDisk || pushing[d] {
|
||||
continue
|
||||
}
|
||||
if _, synced := mapping.RemoteKeyOf(dep); synced {
|
||||
continue
|
||||
}
|
||||
if _, inLedger := keyOf[d]; !inLedger {
|
||||
unsynced = append(unsynced, d)
|
||||
}
|
||||
}
|
||||
if len(unsynced) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: depends on local-only issue(s) %s"+
|
||||
" — no cross-link in the tracker\n", id, strings.Join(unsynced, ", "))
|
||||
}
|
||||
|
||||
msID, ok := milestones[i.Milestone]
|
||||
if i.Milestone != "" && !ok {
|
||||
m, err := client.FindMilestone(i.Milestone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m != nil {
|
||||
msID = wire.Set(m.ID)
|
||||
}
|
||||
milestones[i.Milestone] = msID
|
||||
}
|
||||
if i.Milestone != "" && msID == nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: milestone %q does not exist in %s"+
|
||||
" — not set\n", id, i.Milestone, repo)
|
||||
}
|
||||
|
||||
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
|
||||
sent, synced := mapping.NumberOf(i)
|
||||
var got *wire.Issue
|
||||
verb := "created"
|
||||
if synced {
|
||||
// An edit says what state it means; a create takes the
|
||||
// tracker's default.
|
||||
opt.IncludeState = true
|
||||
verb = "updated"
|
||||
got, err = client.EditIssue(sent, *mapping.ToRequest(i, opt), "issue-"+id)
|
||||
} else {
|
||||
sent = 0
|
||||
got, err = client.CreateIssue(*mapping.ToRequest(i, opt), "issue-"+id)
|
||||
}
|
||||
// THE GATE. Below this line a local file is going to be
|
||||
// deleted, so anything short of a confirmed write stops the
|
||||
// run right here.
|
||||
if err != nil {
|
||||
return Fail("%s: not %s: %v — %s is untouched", id, verb, err, issue.PathOf(root, id))
|
||||
}
|
||||
number, confirmed := pushConfirmedNumber(got, sent)
|
||||
if !confirmed {
|
||||
return Fail("%s: the tracker's answer does not confirm the write "+
|
||||
"(it carries number %d) — %s is untouched, nothing was deleted",
|
||||
id, got.Number, issue.PathOf(root, id))
|
||||
}
|
||||
|
||||
// The number is confirmed, so the ledger learns it NOW —
|
||||
// before the label fix-up and the links, both of which can
|
||||
// still fail, and well before the file is removed. This entry
|
||||
// is what a later `kettle pull <n>` lands on; an interrupted
|
||||
// run must cost a re-pull, never a slug.
|
||||
key := wire.Key{Repo: repo, Number: number}
|
||||
ledger.Set(key, id)
|
||||
keyOf[id] = key
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return Fail("%s: the tracker has it as #%d but the ledger could not be "+
|
||||
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
|
||||
}
|
||||
|
||||
// Gitea occasionally drops labels handed to it on create, so
|
||||
// the echo is checked and the set re-applied rather than
|
||||
// trusted. A failure here is a warning and not an abort: the
|
||||
// issue IS in the tracker, and a run that stopped now would
|
||||
// leave a file whose `gitea:` field was never written — which
|
||||
// the next push would file all over again as a new issue.
|
||||
applied := map[string]bool{}
|
||||
for _, l := range got.Labels {
|
||||
applied[l.Name] = true
|
||||
}
|
||||
var ids []int64
|
||||
var missing []string
|
||||
for _, name := range i.Labels {
|
||||
lid, in := labelIDs[name]
|
||||
if !in {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, lid)
|
||||
if !applied[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
if _, err := client.SetLabels(number, ids, "labels-"+id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not re-apply labels (%s): %v\n",
|
||||
id, strings.Join(missing, ", "), err)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: labels re-applied via PUT (%s)\n",
|
||||
id, strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// The in-memory issue is stamped even though its file is
|
||||
// going: the rest of this loop reads `gitea:` off it to link
|
||||
// dependencies, and a later issue in topological order asks
|
||||
// the same of this one.
|
||||
mapping.ApplyRemote(i, got, repo, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// The number and the URL lead, because in a moment the local
|
||||
// path is gone and this is the only address the issue has.
|
||||
fmt.Printf("%s %s #%d %s\n", verb, id, number, got.HTMLURL)
|
||||
|
||||
if err := pushLinks(client, id, number, i, issues, pushing, keyOf, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// And now the local copy goes: the last thing that happens to
|
||||
// this issue, after the write, the ledger and the links. A
|
||||
// warning above lands here anyway — the issue is in the
|
||||
// tracker, and keeping a stale file beside it would put back
|
||||
// exactly the two-copies question this removes.
|
||||
gone, err := issue.Remove(root, id)
|
||||
for _, p := range gone {
|
||||
fmt.Printf(" dropped %s\n", p)
|
||||
}
|
||||
if err != nil {
|
||||
return Fail("%s: the tracker has it as #%d, but the local copy could not "+
|
||||
"be removed: %v", id, number, err)
|
||||
}
|
||||
fmt.Printf(" kettle pull %d to work on it again\n", number)
|
||||
}
|
||||
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// pushSelect is which issues to send, and the refusal of the ambiguous
|
||||
// combinations.
|
||||
//
|
||||
// Named ids are taken as typed. With none, the default is everything this
|
||||
// machine has never sent — pushing the whole store on a bare `kettle push` would
|
||||
// re-PATCH every working copy in it.
|
||||
func pushSelect(issues map[string]*issue.Issue, ids []string, update bool) ([]string, error) {
|
||||
var chosen []string
|
||||
if len(ids) > 0 {
|
||||
var missing []string
|
||||
for _, id := range ids {
|
||||
if _, ok := issues[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
chosen = append(chosen, ids...)
|
||||
} else {
|
||||
for id, i := range issues {
|
||||
if _, synced := mapping.RemoteKeyOf(i); update || !synced {
|
||||
chosen = append(chosen, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(chosen)
|
||||
if len(chosen) == 0 {
|
||||
return nil, Fail("nothing to push: every issue in the store is already in the " +
|
||||
"tracker — pass --update to PATCH them, or `kettle new` to make one")
|
||||
}
|
||||
}
|
||||
if !update {
|
||||
var already []string
|
||||
for _, id := range chosen {
|
||||
if _, synced := mapping.RemoteKeyOf(issues[id]); synced {
|
||||
already = append(already, id)
|
||||
}
|
||||
}
|
||||
if len(already) > 0 {
|
||||
return nil, Fail("already in the tracker: %s — pass --update to PATCH them",
|
||||
strings.Join(already, ", "))
|
||||
}
|
||||
}
|
||||
return chosen, nil
|
||||
}
|
||||
|
||||
// pushLabelIDs is name -> id for the labels these issues carry, creating what
|
||||
// the repository is missing.
|
||||
//
|
||||
// Decided against the repository as it is right now, in one request, and never
|
||||
// against a cache: a cache answers "what did we create last time", and the
|
||||
// question here is "what does this repository have". A label the tracker does
|
||||
// not have and this cannot create is the one failure worth stopping for — an
|
||||
// issue filed without its `type/*` label is an issue nothing can find again.
|
||||
func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
|
||||
out := map[string]int64{}
|
||||
if len(names) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
have, err := c.ListLabels()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known := make(map[string]int64, len(have))
|
||||
for _, l := range have {
|
||||
known[l.Name] = l.ID
|
||||
}
|
||||
// The spec — colour, description, exclusivity — is the bridge's, read off the
|
||||
// domain's taxonomy. This layer only decides which names are wanted.
|
||||
for _, spec := range mapping.LabelSpecs(names) {
|
||||
if id, ok := known[spec.Name]; ok {
|
||||
out[spec.Name] = id
|
||||
continue
|
||||
}
|
||||
created, err := c.CreateLabel(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[spec.Name] = created.ID
|
||||
note := ""
|
||||
if spec.Exclusive {
|
||||
note = " (exclusive)"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "created label %s%s\n", spec.Name, note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pushDep is what one `depends:` entry is, as far as linking is concerned.
|
||||
type pushDep struct {
|
||||
// Slug is the dependency as `depends:` spells it.
|
||||
Slug string
|
||||
// Key is where it lives in the tracker; HasKey is false while it is
|
||||
// local-only.
|
||||
Key wire.Key
|
||||
HasKey bool
|
||||
// InRun says this push is about to give it a number.
|
||||
InRun bool
|
||||
}
|
||||
|
||||
// pushDepState is every dependency this run can say anything about.
|
||||
//
|
||||
// A dependency's key is read from its `gitea:` field while the file is still on
|
||||
// disk, and from the ledger when it is not — which, since push deletes what it
|
||||
// sends, is the normal state of an already-published blocker. Without that
|
||||
// fallback the graph would quietly lose an edge every time a blocker was pushed
|
||||
// before its dependent: the file is gone, the field goes with it, and the link is
|
||||
// never made.
|
||||
//
|
||||
// A slug that is in neither the store nor the ledger names nothing this machine
|
||||
// has ever seen, and is dropped — validation has already warned about it.
|
||||
func pushDepState(i *issue.Issue, issues map[string]*issue.Issue, pushing map[string]bool,
|
||||
keyOf map[string]wire.Key) []pushDep {
|
||||
var out []pushDep
|
||||
for _, d := range i.Depends {
|
||||
dep, onDisk := issues[d]
|
||||
var key wire.Key
|
||||
found := false
|
||||
if onDisk {
|
||||
key, found = mapping.RemoteKeyOf(dep)
|
||||
}
|
||||
if !found {
|
||||
key, found = keyOf[d]
|
||||
}
|
||||
if !onDisk && !found {
|
||||
continue
|
||||
}
|
||||
out = append(out, pushDep{Slug: d, Key: key, HasKey: found, InRun: pushing[d]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushLinks turns `depends:` into the tracker's own dependency links.
|
||||
//
|
||||
// Topological order means every blocker that is going to have a number has one
|
||||
// already. The GET is the idempotence check — one request per issue that has
|
||||
// dependencies at all, and what makes a repeat push a no-op. A failure is a
|
||||
// warning, never an abort: one missing cross-link must not undo a push that has
|
||||
// already created issues.
|
||||
func pushLinks(c *gitea.Client, id string, number int, i *issue.Issue,
|
||||
issues map[string]*issue.Issue, pushing map[string]bool,
|
||||
keyOf map[string]wire.Key, repo wire.Repo) error {
|
||||
var wanted []pushDep
|
||||
for _, d := range pushDepState(i, issues, pushing, keyOf) {
|
||||
if d.HasKey && d.Key.Number > 0 {
|
||||
wanted = append(wanted, d)
|
||||
}
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
have, err := c.DependencyKeys(number)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not read the links #%d already has (%v)"+
|
||||
" — no link was made\n", id, number, err)
|
||||
return nil
|
||||
}
|
||||
for _, d := range wanted {
|
||||
key := d.Key.In(repo)
|
||||
if containsKey(have, key) {
|
||||
continue
|
||||
}
|
||||
if err := c.AddDependency(number, key); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not link #%d -> %s (%s): %v — link it by "+
|
||||
"hand, or `kettle pull %d` and push it again\n", id, number, key, d.Slug, err, number)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" depends on %s (%s)\n", key, d.Slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushPlan is the --dry-run receipt: what would be sent, and which links would
|
||||
// exist. `#?` is a number this run has not handed out yet.
|
||||
func pushPlan(root string, issues map[string]*issue.Issue, order []string,
|
||||
pushing map[string]bool, update bool) {
|
||||
// The ledger costs no request, so a dry run resolves an already-pushed
|
||||
// blocker exactly the way the real run does.
|
||||
keyOf := pushLedgerKeys(loadLedgerOrFold(root, issues), wire.Repo{})
|
||||
links := 0
|
||||
for _, id := range order {
|
||||
i := issues[id]
|
||||
typ := i.Type()
|
||||
if typ == "" {
|
||||
typ = "?"
|
||||
}
|
||||
labels := strings.Join(i.Labels, ", ")
|
||||
if labels == "" {
|
||||
labels = "no labels"
|
||||
}
|
||||
fmt.Printf("ok %s [type/%s] %s (%s)\n", id, typ, i.Title, labels)
|
||||
for _, d := range pushDepState(i, issues, pushing, keyOf) {
|
||||
switch {
|
||||
case d.HasKey:
|
||||
fmt.Printf(" link -> %s (%s)\n", d.Key, d.Slug)
|
||||
links++
|
||||
case d.InRun:
|
||||
fmt.Printf(" link -> #? (%s, created by this run)\n", d.Slug)
|
||||
links++
|
||||
default:
|
||||
fmt.Printf(" no link: %s is local-only\n", d.Slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
verb := "created"
|
||||
if update {
|
||||
verb = "updated"
|
||||
}
|
||||
fmt.Printf("%d issue(s) would be %s, %d dependency link(s) would be created\n",
|
||||
len(order), verb, links)
|
||||
}
|
||||
|
||||
// pushLedgerKeys is slug -> key, the reverse of the ledger.
|
||||
//
|
||||
// Where a dependency's number comes from once push has deleted its file. The
|
||||
// ledger is keyed by number because that is what a pull has in hand; a push has a
|
||||
// slug, so it needs the other direction. An entry in the repository being pushed
|
||||
// to wins when a slug somehow appears under two keys.
|
||||
func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
|
||||
raw := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
raw = append(raw, k)
|
||||
}
|
||||
sort.Strings(raw)
|
||||
|
||||
out := map[string]wire.Key{}
|
||||
for _, r := range raw {
|
||||
key, err := wire.ParseKey(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
slug := m[r]
|
||||
if _, seen := out[slug]; !seen || key.Repo == repo {
|
||||
out[slug] = key
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushConfirmedNumber is the number the tracker confirmed for a write, or ok
|
||||
// false — the deletion gate.
|
||||
//
|
||||
// Every local file this command removes is removed because this returned ok, so
|
||||
// it is written to be boring and to say no by default: a positive number, and on
|
||||
// a PATCH the very number that was addressed. What it does not have to catch,
|
||||
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
|
||||
// expected, or a `number` that is not a number — the transport fails all three
|
||||
// before returning, and the file survives by never reaching the delete.
|
||||
func pushConfirmedNumber(got *wire.Issue, sent int) (int, bool) {
|
||||
if got == nil || got.Number <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if sent != 0 && got.Number != sent {
|
||||
return 0, false
|
||||
}
|
||||
return got.Number, true
|
||||
}
|
||||
|
||||
// pushGitBranch is the branch HEAD is on, or "".
|
||||
//
|
||||
// The one git call this binary makes — read, never write. A detached HEAD prints
|
||||
// `HEAD` and outside a repository git exits non-zero; both mean "no branch to
|
||||
// name", which is not an error.
|
||||
func pushGitBranch() string {
|
||||
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(string(out))
|
||||
if name == "HEAD" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func containsKey(keys []wire.Key, want wire.Key) bool {
|
||||
for _, k := range keys {
|
||||
if k == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// labelColumn is how much of the label list a row shows before it is cut.
|
||||
const labelColumn = 38
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "remote",
|
||||
Group: GroupSync,
|
||||
Short: "list what exists in the tracker, one line each",
|
||||
Long: `Discovery only: this prints and WRITES NOTHING. The local store is a store, not a
|
||||
search-results folder, and a listing that landed in it would leave files nobody
|
||||
asked for beside the issues somebody did. Pick the numbers here, then pull them.
|
||||
|
||||
#42 open type/task, tech/sql Wire sqlc into the repo layer
|
||||
└─ local: wire-sqlc-appclick
|
||||
|
||||
The second line appears when the number is already in the local ledger, so it is
|
||||
obvious what a pull would refresh and what it would add.
|
||||
|
||||
--limit here caps the LISTING: N lines out, closed ones among them. That is not
|
||||
what the same flag means to ` + "`kettle pull`" + `, and the difference is not an oversight —
|
||||
pull bounds what it WRITES, this command writes nothing, and enumeration is the
|
||||
whole job.
|
||||
|
||||
Projects are not filterable: the projects API is not exposed by Gitea. Use
|
||||
milestones or labels, or the web UI.`,
|
||||
Examples: []Example{
|
||||
{"kettle remote", "the open issues, 30 of them"},
|
||||
{"kettle remote --state all --label type/bug --limit 50", "every bug, open and closed"},
|
||||
{"kettle remote --milestone v0.2", "what is in a milestone"},
|
||||
{"kettle remote -q sqlc", "keyword search over title and body"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
state := fs.String("state", "open", "open, closed or all")
|
||||
var labels stringList
|
||||
fs.Var(&labels, "label", "filter by label; repeat for AND")
|
||||
// Both spellings, the way the Python this replaces took them.
|
||||
var query string
|
||||
fs.StringVar(&query, "q", "", "search text in title and body")
|
||||
fs.StringVar(&query, "query", "", "the long spelling of -q")
|
||||
milestone := fs.String("milestone", "", "milestone id or title")
|
||||
limit := fs.Int("limit", 30, "how many lines to print")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) > 0 {
|
||||
return Fail("remote takes no arguments — filter with --label, --milestone or -q")
|
||||
}
|
||||
if !contains([]string{"open", "closed", "all"}, *state) {
|
||||
return Fail("--state %q must be open, closed or all", *state)
|
||||
}
|
||||
if *limit < 1 {
|
||||
return Fail("--limit must be 1 or more, got %d", *limit)
|
||||
}
|
||||
|
||||
root, client, err := syncStart(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listing, err := client.ListIssues(gitea.IssueFilter{
|
||||
State: *state, Labels: labels, Query: query,
|
||||
Milestone: *milestone, Limit: *limit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The ledger, not the files: a pushed issue has no file left and
|
||||
// is still something a pull would land on a known slug.
|
||||
ledger := gitea.LoadRemoteMap(root)
|
||||
repo := client.Repo()
|
||||
|
||||
for i := range listing.Issues {
|
||||
p := &listing.Issues[i]
|
||||
labels := "-"
|
||||
if names := p.LabelNames(); len(names) > 0 {
|
||||
labels = strings.Join(names, ", ")
|
||||
}
|
||||
// One line per issue is the whole point; a repository that
|
||||
// namespaces heavily would wrap the column otherwise.
|
||||
if len(labels) > labelColumn {
|
||||
labels = labels[:labelColumn]
|
||||
}
|
||||
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Number, p.State, labels, p.Title)
|
||||
if local := ledger.Slug(wire.Key{Repo: repo, Number: p.Number}); local != "" {
|
||||
fmt.Printf("%13s└─ local: %s\n", "", local)
|
||||
}
|
||||
}
|
||||
|
||||
scope := ""
|
||||
if listing.Milestone != "" {
|
||||
scope = " in milestone " + listing.Milestone
|
||||
}
|
||||
hint := "<n>"
|
||||
if *milestone != "" {
|
||||
hint = "--milestone " + *milestone
|
||||
}
|
||||
fmt.Printf("%d issue(s)%s — pull them with: kettle pull %s\n",
|
||||
len(listing.Issues), scope, hint)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
// commentsSidecarPath is where an issue's comment thread lives — beside it,
|
||||
// under the same slug.
|
||||
//
|
||||
// A path, not a concept the domain needs: a thread is pulled from the tracker
|
||||
// and never pushed back, so `internal/issue` has no reason to learn that the
|
||||
// file exists. It does not have to — the file is named after the issue, and
|
||||
// issue.SlugFiles takes it away when the issue goes.
|
||||
//
|
||||
// It sits here rather than in either command because `pull` writes it and
|
||||
// `comment` rewrites it, and two spellings of one path is how the two come to
|
||||
// disagree about where a thread is.
|
||||
func commentsSidecarPath(root, id string) string {
|
||||
return filepath.Join(root, id+".comments.md")
|
||||
}
|
||||
|
||||
// The sync commands all start the same way and must fail the same way.
|
||||
//
|
||||
// Every one of them needs a store and a client, and the order matters: a
|
||||
// command that dialled first would report a network problem for a project that
|
||||
// was never initialized, and an operator would go looking at the wrong thing.
|
||||
// So the store is resolved before a socket is opened, and each failure names
|
||||
// the command that fixes it.
|
||||
|
||||
// syncStart resolves the store and builds a client for it.
|
||||
//
|
||||
// The client is built from the project's own configuration, which is why there
|
||||
// is no --login flag anywhere in this tree: which login a project runs under is
|
||||
// a fact about the project, stated once by `kettle init`, not a thing a caller
|
||||
// gets to differ about per invocation. That the two could disagree is what the
|
||||
// Python version needed a PreToolUse hook to police.
|
||||
func syncStart(out string) (string, *gitea.Client, error) {
|
||||
root, err := storeRoot(out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cfg, err := config.Require("")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
client, err := gitea.New(cfg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return root, client, nil
|
||||
}
|
||||
|
||||
// syncStartExisting is syncStart for the commands that read the store rather
|
||||
// than create it: push, comment, close and the sync form of evict all operate
|
||||
// on issues that are already on disk, and a missing store is a mistake to
|
||||
// report, not a directory to conjure.
|
||||
func syncStartExisting(out string) (string, *gitea.Client, error) {
|
||||
root, client, err := syncStart(out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := issue.RequireStore(root); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return root, client, nil
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
package cmd_test
|
||||
|
||||
// The transport, end to end: the real binary, run as a subprocess against a
|
||||
// throwaway project, talking to an httptest server that speaks enough of the
|
||||
// Gitea REST API to answer it.
|
||||
//
|
||||
// Enough and no more. What is worth proving here is not that JSON round-trips —
|
||||
// internal/mapping has tests for that, without a server anywhere — but the two
|
||||
// rules that cost work when they are wrong: a confirmed push takes the local file
|
||||
// with it, and an unconfirmed one does not touch it.
|
||||
//
|
||||
// The repository is always owner/repo, and the credentials arrive through
|
||||
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
|
||||
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
|
||||
// overwrite the developer's own tokens.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fake tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
|
||||
type pullFakeGitea struct {
|
||||
mu sync.Mutex
|
||||
issues map[int]*wire.Issue
|
||||
deps map[int][]int
|
||||
comments map[int][]wire.Comment
|
||||
labels map[string]int64
|
||||
next int
|
||||
|
||||
// writesFail makes every issue create and edit answer 500 — the failure a
|
||||
// push has to survive without losing a file.
|
||||
writesFail bool
|
||||
}
|
||||
|
||||
func pullNewGitea() *pullFakeGitea {
|
||||
return &pullFakeGitea{
|
||||
issues: map[int]*wire.Issue{},
|
||||
deps: map[int][]int{},
|
||||
comments: map[int][]wire.Comment{},
|
||||
labels: map[string]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
// pullAdd puts an issue in the tracker the way the web UI would: it is there
|
||||
// before this project ever hears about it.
|
||||
func (g *pullFakeGitea) pullAdd(p wire.Issue) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p.State == "" {
|
||||
p.State = "open"
|
||||
}
|
||||
p.HTMLURL = pullURL(p.Number)
|
||||
g.issues[p.Number] = &p
|
||||
if p.Number > g.next {
|
||||
g.next = p.Number
|
||||
}
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullIssue(n int) wire.Issue {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p := g.issues[n]; p != nil {
|
||||
return *p
|
||||
}
|
||||
return wire.Issue{}
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullRetitle(n int, title string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.issues[n].Title = title
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullBlocks(blocked int, blockers ...int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.deps[blocked] = append(g.deps[blocked], blockers...)
|
||||
}
|
||||
|
||||
func pullURL(n int) string {
|
||||
return fmt.Sprintf("https://git.example.com/owner/repo/issues/%d", n)
|
||||
}
|
||||
|
||||
var (
|
||||
pullIssueRoute = regexp.MustCompile(`^issues/(\d+)$`)
|
||||
pullSubRoute = regexp.MustCompile(`^issues/(\d+)/(dependencies|comments|labels)$`)
|
||||
)
|
||||
|
||||
func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
|
||||
if !ok {
|
||||
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "labels" && r.Method == http.MethodGet:
|
||||
out := []wire.Label{}
|
||||
for name, id := range g.labels {
|
||||
out = append(out, wire.Label{ID: id, Name: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
pullJSON(w, out)
|
||||
|
||||
case path == "labels" && r.Method == http.MethodPost:
|
||||
var req wire.LabelRequest
|
||||
pullDecode(r, &req)
|
||||
id := int64(1000 + len(g.labels))
|
||||
g.labels[req.Name] = id
|
||||
pullJSON(w, wire.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
|
||||
|
||||
case path == "milestones" && r.Method == http.MethodGet:
|
||||
pullJSON(w, []wire.Milestone{})
|
||||
|
||||
case path == "issues" && r.Method == http.MethodPost:
|
||||
if g.writesFail {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var req wire.IssueRequest
|
||||
pullDecode(r, &req)
|
||||
g.next++
|
||||
p := &wire.Issue{
|
||||
Number: g.next, Title: pullStr(req.Title), Body: pullStr(req.Body),
|
||||
State: "open", HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
|
||||
}
|
||||
g.issues[p.Number] = p
|
||||
pullJSON(w, p)
|
||||
|
||||
case path == "issues" && r.Method == http.MethodGet:
|
||||
g.pullList(w, r)
|
||||
|
||||
case pullIssueRoute.MatchString(path):
|
||||
n := pullNumber(pullIssueRoute, path)
|
||||
p := g.issues[n]
|
||||
if p == nil {
|
||||
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
if g.writesFail {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var req wire.IssueRequest
|
||||
pullDecode(r, &req)
|
||||
if req.Title != nil {
|
||||
p.Title = *req.Title
|
||||
}
|
||||
if req.Body != nil {
|
||||
p.Body = *req.Body
|
||||
}
|
||||
if req.State != nil {
|
||||
p.State = *req.State
|
||||
}
|
||||
if req.Labels != nil {
|
||||
p.Labels = g.pullLabelsFor(req.Labels)
|
||||
}
|
||||
}
|
||||
pullJSON(w, p)
|
||||
|
||||
case pullSubRoute.MatchString(path):
|
||||
m := pullSubRoute.FindStringSubmatch(path)
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
switch {
|
||||
case m[2] == "dependencies" && r.Method == http.MethodGet:
|
||||
out := []wire.Issue{}
|
||||
for _, d := range g.deps[n] {
|
||||
if p := g.issues[d]; p != nil {
|
||||
out = append(out, *p)
|
||||
}
|
||||
}
|
||||
pullJSON(w, out)
|
||||
case m[2] == "dependencies" && r.Method == http.MethodPost:
|
||||
var req struct {
|
||||
Index int `json:"index"`
|
||||
}
|
||||
pullDecode(r, &req)
|
||||
g.deps[n] = append(g.deps[n], req.Index)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case m[2] == "comments" && r.Method == http.MethodGet:
|
||||
out := g.comments[n]
|
||||
if out == nil {
|
||||
out = []wire.Comment{}
|
||||
}
|
||||
pullJSON(w, out)
|
||||
case m[2] == "labels" && r.Method == http.MethodPut:
|
||||
var req struct {
|
||||
Labels []int64 `json:"labels"`
|
||||
}
|
||||
pullDecode(r, &req)
|
||||
g.issues[n].Labels = g.pullLabelsFor(&req.Labels)
|
||||
pullJSON(w, g.issues[n].Labels)
|
||||
default:
|
||||
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// pullList is the filtered listing, paginated the way the client asks for it.
|
||||
func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
state, page, limit := q.Get("state"), 1, 50
|
||||
if v, err := strconv.Atoi(q.Get("page")); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
var want []string
|
||||
if v := q.Get("labels"); v != "" {
|
||||
want = strings.Split(v, ",")
|
||||
}
|
||||
|
||||
numbers := make([]int, 0, len(g.issues))
|
||||
for n := range g.issues {
|
||||
numbers = append(numbers, n)
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
|
||||
out := []wire.Issue{}
|
||||
for _, n := range numbers {
|
||||
p := g.issues[n]
|
||||
if state != "" && state != "all" && p.State != state {
|
||||
continue
|
||||
}
|
||||
has := map[string]bool{}
|
||||
for _, l := range p.Labels {
|
||||
has[l.Name] = true
|
||||
}
|
||||
missing := false
|
||||
for _, l := range want {
|
||||
missing = missing || !has[l]
|
||||
}
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
out = append(out, *p)
|
||||
}
|
||||
|
||||
start := (page - 1) * limit
|
||||
if start > len(out) {
|
||||
start = len(out)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
pullJSON(w, out[start:end])
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
|
||||
if ids == nil {
|
||||
return nil
|
||||
}
|
||||
byID := map[int64]string{}
|
||||
for name, id := range g.labels {
|
||||
byID[id] = name
|
||||
}
|
||||
var out []wire.Label
|
||||
for _, id := range *ids {
|
||||
if name, ok := byID[id]; ok {
|
||||
out = append(out, wire.Label{ID: id, Name: name})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pullNumber(re *regexp.Regexp, path string) int {
|
||||
n, _ := strconv.Atoi(re.FindStringSubmatch(path)[1])
|
||||
return n
|
||||
}
|
||||
|
||||
func pullDecode(r *http.Request, into any) {
|
||||
_ = json.NewDecoder(r.Body).Decode(into)
|
||||
}
|
||||
|
||||
func pullJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func pullStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// pullEnv starts the fake and returns the environment that points the binary at
|
||||
// it. The credential home is a temp directory: a test run may neither read nor
|
||||
// overwrite the developer's own tokens.
|
||||
func pullEnv(t *testing.T, g *pullFakeGitea) []string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(g)
|
||||
t.Cleanup(srv.Close)
|
||||
return []string{
|
||||
config.EnvURL + "=" + srv.URL,
|
||||
config.EnvToken + "=t0ken",
|
||||
config.EnvRepo + "=owner/repo",
|
||||
config.EnvHome + "=" + t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
func pullStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
|
||||
|
||||
func pullRead(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func pullExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// push
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// The rule the whole design rests on: once the tracker has the issue, the
|
||||
// tracker IS the issue, and the local copy goes — sidecars included.
|
||||
func TestPushCreatesTheIssueAndTakesTheLocalCopyWithIt(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
env := pullEnv(t, g)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
|
||||
const id = "wire-sqlc-into-the-appclick-layer"
|
||||
store := pullStore(dir)
|
||||
sidecar := filepath.Join(store, id+".comments.md")
|
||||
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "push")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("push exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
// The number and the URL lead: in a moment they are the only address the
|
||||
// issue has.
|
||||
if !strings.Contains(r.stdout, "created "+id+" #1 "+pullURL(1)) {
|
||||
t.Errorf("the receipt does not say where the issue lives now:\n%s", r.stdout)
|
||||
}
|
||||
|
||||
if pullExists(filepath.Join(store, id+".md")) {
|
||||
t.Error("the local file survived a confirmed push — what is in the store is what has not left")
|
||||
}
|
||||
if pullExists(sidecar) {
|
||||
t.Error("the sidecar was left behind; every file under the slug goes")
|
||||
}
|
||||
|
||||
// The ledger is what makes the slug come back, so it has to hold the number.
|
||||
ledger := pullRead(t, filepath.Join(store, ".remote.json"))
|
||||
if !strings.Contains(ledger, `"owner/repo#1": "`+id+`"`) {
|
||||
t.Errorf("the ledger does not index the number:\n%s", ledger)
|
||||
}
|
||||
// And the slug travelled up in the body, which is what survives a lost ledger.
|
||||
if body := g.pullIssue(1).Body; !strings.Contains(body, "<!-- kettle:id "+id+" -->") {
|
||||
t.Errorf("the id marker did not go up with the issue:\n%s", body)
|
||||
}
|
||||
if !strings.HasPrefix(g.pullIssue(1).Body, "<!-- kettle:id") {
|
||||
t.Error("the marker must be the first line of the tracker-side body")
|
||||
}
|
||||
}
|
||||
|
||||
// Network down, non-2xx, an answer that does not confirm the write: the file
|
||||
// stays and the run stops. Nothing is deleted that was not just accepted.
|
||||
func TestPushLeavesTheFileWhenTheTrackerRefuses(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.writesFail = true
|
||||
env := pullEnv(t, g)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Never made it up")
|
||||
const id = "never-made-it-up"
|
||||
path := filepath.Join(pullStore(dir), id+".md")
|
||||
before := pullRead(t, path)
|
||||
|
||||
r := runWith(t, dir, env, "", "push")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("a tracker that refuses the write must fail the run:\n%s", r.out())
|
||||
}
|
||||
if after := pullRead(t, path); after != before {
|
||||
t.Errorf("the file was touched by a push that never landed:\n%s", after)
|
||||
}
|
||||
// The message has to name the file, because "is my only copy still there" is
|
||||
// the question an operator has at that moment.
|
||||
if !strings.Contains(r.stderr, path) {
|
||||
t.Errorf("the failure does not name the file it did not touch:\n%s", r.stderr)
|
||||
}
|
||||
if pullExists(filepath.Join(pullStore(dir), ".remote.json")) {
|
||||
t.Error("a ledger entry was written for an issue the tracker never confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// pull
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// A number is an address, not a query. Only filter mode leaves closed issues out.
|
||||
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{
|
||||
Number: 7, Title: "Closed but addressable", State: "closed",
|
||||
Body: "## Summary\nДело сделано.\n", UpdatedAt: "2026-08-01T10:00:00Z",
|
||||
})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
r := runWith(t, dir, env, "", "pull", "7")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
file := pullRead(t, filepath.Join(pullStore(dir), "closed-but-addressable.md"))
|
||||
if !strings.Contains(file, "state: closed") {
|
||||
t.Errorf("the closed state did not land on disk:\n%s", file)
|
||||
}
|
||||
if !strings.Contains(file, "gitea: owner/repo#7") {
|
||||
t.Errorf("the cross-repo handle is missing:\n%s", file)
|
||||
}
|
||||
if !strings.Contains(file, "origin: gitea") {
|
||||
t.Errorf("the issue does not say it exists elsewhere:\n%s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// A pull answers with the unit of work — the issue and what blocks it — and
|
||||
// --no-deps is how you ask for one row of it.
|
||||
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{Number: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
|
||||
g.pullAdd(wire.Issue{Number: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
|
||||
g.pullBlocks(2, 1)
|
||||
env := pullEnv(t, g)
|
||||
|
||||
t.Run("by default", func(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
if r := runWith(t, dir, env, "", "pull", "2"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
|
||||
t.Fatal("the blocker did not come down — a pull returns the unit of work")
|
||||
}
|
||||
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
|
||||
if !strings.Contains(dependent, "depends: [migrate-the-schema]") {
|
||||
t.Errorf("depends: was not filled from the tracker's own graph:\n%s", dependent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--no-deps", func(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
if r := runWith(t, dir, env, "", "pull", "2", "--no-deps"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
|
||||
t.Error("--no-deps followed a blocker anyway")
|
||||
}
|
||||
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
|
||||
if !strings.Contains(dependent, "depends: []") {
|
||||
t.Errorf("--no-deps filled depends: anyway:\n%s", dependent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The round trip, and the two things that carry the slug through it: the ledger,
|
||||
// and — when the ledger is gone, as it is in a fresh clone — the marker in the
|
||||
// body. A rename in the web UI changes neither.
|
||||
func TestAPushedIssueComesBackUnderItsOriginalSlug(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
env := pullEnv(t, g)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
|
||||
const id = "wire-sqlc-into-the-appclick-layer"
|
||||
store := pullStore(dir)
|
||||
|
||||
if r := runWith(t, dir, env, "", "push"); r.code != 0 {
|
||||
t.Fatalf("push exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if pullExists(filepath.Join(store, id+".md")) {
|
||||
t.Fatal("push did not drop the local copy")
|
||||
}
|
||||
g.pullRetitle(1, "Somebody retitled this in the web UI")
|
||||
|
||||
// The ledger knows the number, so it wins.
|
||||
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
file := pullRead(t, filepath.Join(store, id+".md"))
|
||||
if !strings.Contains(file, "# Somebody retitled this in the web UI") {
|
||||
t.Errorf("the new title did not come down:\n%s", file)
|
||||
}
|
||||
// The marker is transport bookkeeping and never reaches the store.
|
||||
if strings.Contains(file, "kettle:id") {
|
||||
t.Errorf("the id marker was written into the local file:\n%s", file)
|
||||
}
|
||||
|
||||
// Now lose both the file and the ledger, the way a fresh clone has neither.
|
||||
// The marker in the body is all that is left, and it is enough.
|
||||
for _, p := range []string{filepath.Join(store, id+".md"), filepath.Join(store, ".remote.json")} {
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !pullExists(filepath.Join(store, id+".md")) {
|
||||
names, _ := os.ReadDir(store)
|
||||
var have []string
|
||||
for _, e := range names {
|
||||
have = append(have, e.Name())
|
||||
}
|
||||
t.Fatalf("the issue came back under another name — every depends: pointing at it now "+
|
||||
"dangles; the store holds: %s", strings.Join(have, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// A closed issue is not a unit of work, so a FILTER enumerates it and leaves it
|
||||
// out — the exact opposite of what a key does, and only --state closed changes
|
||||
// it.
|
||||
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
|
||||
g := pullNewGitea()
|
||||
bug := []wire.Label{{ID: 1, Name: "type/bug"}}
|
||||
g.pullAdd(wire.Issue{Number: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
|
||||
g.pullAdd(wire.Issue{Number: 2, Title: "Fixed last week", State: "closed",
|
||||
Body: "## Summary\nx\n", Labels: bug})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
dir := newProject(t)
|
||||
r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "all")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
|
||||
t.Error("a filter stored a closed issue")
|
||||
}
|
||||
// Nothing is dropped in silence.
|
||||
if !strings.Contains(r.stderr, "1 closed issue(s) enumerated, not stored") {
|
||||
t.Errorf("the closed issue went out without a word:\n%s", r.stderr)
|
||||
}
|
||||
|
||||
// Naming the state is how you ask for one.
|
||||
if r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "closed"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
|
||||
t.Error("--state closed did not store the closed issue")
|
||||
}
|
||||
}
|
||||
|
||||
// ONE RULE, NO EXCEPTION: a PATCH is a push, and it drops the local copy too.
|
||||
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{
|
||||
Number: 3, Title: "Came down and went back up",
|
||||
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
|
||||
Labels: []wire.Label{{ID: 1, Name: "type/task"}},
|
||||
})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
if r := runWith(t, dir, env, "", "pull", "3"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
const id = "came-down-and-went-back-up"
|
||||
path := filepath.Join(pullStore(dir), id+".md")
|
||||
if !pullExists(path) {
|
||||
t.Fatal("the issue did not arrive")
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "push", "--update", id)
|
||||
if r.code != 0 {
|
||||
t.Fatalf("push --update exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "updated "+id+" #3") {
|
||||
t.Errorf("the receipt does not report the PATCH:\n%s", r.stdout)
|
||||
}
|
||||
if pullExists(path) {
|
||||
t.Error("--update kept the local file — two rules would put back the question " +
|
||||
"push exists to remove")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run makes no request, so it must not need a credential to say what it
|
||||
// would do — no URL, no token, no repository in the environment at all.
|
||||
func TestPushDryRunNeedsNoCredential(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Planned but not sent")
|
||||
|
||||
r := run(t, dir, "push", "--dry-run")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("a dry run must not need a tracker:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "ok planned-but-not-sent") ||
|
||||
!strings.Contains(r.stdout, "1 issue(s) would be created") {
|
||||
t.Errorf("the plan was not printed:\n%s", r.stdout)
|
||||
}
|
||||
if !pullExists(filepath.Join(pullStore(dir), "planned-but-not-sent.md")) {
|
||||
t.Error("a dry run deleted the issue")
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// remote
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Discovery writes nothing: the store is a store, not a search-results folder.
|
||||
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(wire.Issue{Number: 4, Title: "Something open", Body: "x"})
|
||||
g.pullAdd(wire.Issue{Number: 5, Title: "Something closed", State: "closed", Body: "x"})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
r := runWith(t, dir, env, "", "remote")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("remote exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "#4") || strings.Contains(r.stdout, "#5") {
|
||||
t.Errorf("the default listing is the open issues:\n%s", r.stdout)
|
||||
}
|
||||
if entries, err := os.ReadDir(pullStore(dir)); err != nil || len(entries) != 0 {
|
||||
t.Errorf("a listing left files in the store: %v", entries)
|
||||
}
|
||||
|
||||
// A number the store already knows about says so, so it is obvious what a
|
||||
// pull would refresh and what it would add.
|
||||
if r := runWith(t, dir, env, "", "pull", "4"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
again := runWith(t, dir, env, "", "remote")
|
||||
if !strings.Contains(again.stdout, "└─ local: something-open") {
|
||||
t.Errorf("the local slug was not reported:\n%s", again.stdout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package cmd_test
|
||||
|
||||
// The four commands that WRITE — comment, close, labels, sync-evict — end to
|
||||
// end: the real binary, in a throwaway project, against an httptest server
|
||||
// speaking enough of the Gitea API to answer them.
|
||||
//
|
||||
// A fake tracker rather than a mocked client, because what these commands are
|
||||
// trusted to get right is exactly the part a mock would stand in for: what goes
|
||||
// out, and what is believed about the answer. `sync-evict` deletes files on the
|
||||
// strength of a payload, so the payload has to come off a socket.
|
||||
//
|
||||
// Every helper here is named `wr…` so it cannot collide with the read side's.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
const (
|
||||
wrRepo = "kettle/tests"
|
||||
wrToken = "s3cr3t-token"
|
||||
wrWhen = "2026-08-11T12:00:00Z"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fake tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type wrLabel struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
Exclusive bool `json:"exclusive"`
|
||||
}
|
||||
|
||||
type wrIssue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type wrUser struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type wrComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
User wrUser `json:"user"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
wrIssuePath = regexp.MustCompile(`^issues/(\d+)$`)
|
||||
wrCommentPath = regexp.MustCompile(`^issues/(\d+)/comments$`)
|
||||
wrLabelPath = regexp.MustCompile(`^labels/(\d+)$`)
|
||||
)
|
||||
|
||||
// wrTracker is one repository on a pretend Gitea. It records every call, so a
|
||||
// test can assert that a dry run sent nothing and that a second bootstrap wrote
|
||||
// nothing.
|
||||
type wrTracker struct {
|
||||
mu sync.Mutex
|
||||
labels []wrLabel
|
||||
issues map[int]*wrIssue
|
||||
comments map[int][]wrComment
|
||||
broken map[int]bool // numbers whose GET answers 500
|
||||
calls []string
|
||||
next int64
|
||||
}
|
||||
|
||||
func wrNewTracker() *wrTracker {
|
||||
return &wrTracker{
|
||||
issues: map[int]*wrIssue{},
|
||||
comments: map[int][]wrComment{},
|
||||
broken: map[int]bool{},
|
||||
next: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
|
||||
|
||||
// The scheme Gitea uses and the client sends: the word `token`.
|
||||
if r.Header.Get("Authorization") != "token "+wrToken {
|
||||
http.Error(w, `{"message":"token required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/"+wrRepo+"/")
|
||||
if !ok {
|
||||
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "labels" && r.Method == http.MethodGet:
|
||||
wrJSON(w, tr.labels)
|
||||
|
||||
case path == "labels" && r.Method == http.MethodPost:
|
||||
var req wrLabel
|
||||
wrDecode(r, &req)
|
||||
tr.next++
|
||||
req.ID = tr.next
|
||||
tr.labels = append(tr.labels, req)
|
||||
wrJSON(w, req)
|
||||
|
||||
case r.Method == http.MethodPatch && wrLabelPath.MatchString(path):
|
||||
id, _ := strconv.ParseInt(wrLabelPath.FindStringSubmatch(path)[1], 10, 64)
|
||||
var req wrLabel
|
||||
wrDecode(r, &req)
|
||||
for i := range tr.labels {
|
||||
if tr.labels[i].ID == id {
|
||||
req.ID = id
|
||||
tr.labels[i] = req
|
||||
wrJSON(w, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, `{"message":"no such label"}`, http.StatusNotFound)
|
||||
|
||||
case wrIssuePath.MatchString(path):
|
||||
n, _ := strconv.Atoi(wrIssuePath.FindStringSubmatch(path)[1])
|
||||
if tr.broken[n] {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
got := tr.issues[n]
|
||||
if got == nil {
|
||||
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
var req struct {
|
||||
State *string `json:"state"`
|
||||
Title *string `json:"title"`
|
||||
}
|
||||
wrDecode(r, &req)
|
||||
// A close is state and nothing else; a title arriving here would be
|
||||
// the command editing an issue it was only asked to close.
|
||||
if req.Title != nil {
|
||||
http.Error(w, `{"message":"close sent a title"}`, http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if req.State != nil {
|
||||
got.State = *req.State
|
||||
}
|
||||
got.UpdatedAt = wrWhen
|
||||
}
|
||||
wrJSON(w, got)
|
||||
|
||||
case wrCommentPath.MatchString(path):
|
||||
n, _ := strconv.Atoi(wrCommentPath.FindStringSubmatch(path)[1])
|
||||
if tr.issues[n] == nil {
|
||||
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
wrDecode(r, &req)
|
||||
tr.next++
|
||||
c := wrComment{
|
||||
ID: tr.next,
|
||||
Body: req.Body,
|
||||
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d#issuecomment-%d", wrRepo, n, tr.next),
|
||||
User: wrUser{Login: "tester"},
|
||||
CreatedAt: wrWhen,
|
||||
}
|
||||
tr.comments[n] = append(tr.comments[n], c)
|
||||
wrJSON(w, c)
|
||||
return
|
||||
}
|
||||
wrJSON(w, tr.comments[n])
|
||||
|
||||
default:
|
||||
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func wrJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func wrDecode(r *http.Request, v any) {
|
||||
_ = json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// --- what the tracker holds, for a test to arrange and to read back ---------
|
||||
|
||||
func (tr *wrTracker) add(number int, title, state string) {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
tr.issues[number] = &wrIssue{
|
||||
Number: number,
|
||||
Title: title,
|
||||
State: state,
|
||||
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d", wrRepo, number),
|
||||
UpdatedAt: wrWhen,
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *wrTracker) breaks(number int) {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
tr.broken[number] = true
|
||||
}
|
||||
|
||||
func (tr *wrTracker) state(number int) string {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
if got := tr.issues[number]; got != nil {
|
||||
return got.State
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (tr *wrTracker) label(name string) *wrLabel {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
for i := range tr.labels {
|
||||
if tr.labels[i].Name == name {
|
||||
out := tr.labels[i]
|
||||
return &out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tr *wrTracker) labelCount() int {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return len(tr.labels)
|
||||
}
|
||||
|
||||
func (tr *wrTracker) thread(number int) []wrComment {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return append([]wrComment{}, tr.comments[number]...)
|
||||
}
|
||||
|
||||
// mark is where the log has got to, so a test can ask what one run sent.
|
||||
func (tr *wrTracker) mark() int {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return len(tr.calls)
|
||||
}
|
||||
|
||||
func (tr *wrTracker) since(mark int) []string {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return append([]string{}, tr.calls[mark:]...)
|
||||
}
|
||||
|
||||
func (tr *wrTracker) count(method string) int {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range tr.calls {
|
||||
if strings.HasPrefix(c, method+" ") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fixture
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// wrProject is an initialized project pointed at a fake tracker.
|
||||
//
|
||||
// The credentials arrive through the environment, which is what they are there
|
||||
// for — and KETTLE_CONFIG_HOME goes at a temp directory so a run can neither
|
||||
// read nor overwrite the developer's own tokens. KETTLE_LOGIN is cleared for the
|
||||
// same reason: a value in the developer's shell would send every fixture
|
||||
// looking for a login that is not in the temp file.
|
||||
func wrProject(t *testing.T) (dir string, tr *wrTracker, env []string) {
|
||||
t.Helper()
|
||||
dir = newProject(t)
|
||||
tr = wrNewTracker()
|
||||
srv := httptest.NewServer(tr)
|
||||
t.Cleanup(srv.Close)
|
||||
return dir, tr, []string{
|
||||
"KETTLE_URL=" + srv.URL,
|
||||
"KETTLE_TOKEN=" + wrToken,
|
||||
"KETTLE_REPO=" + wrRepo,
|
||||
"KETTLE_CONFIG_HOME=" + t.TempDir(),
|
||||
"KETTLE_LOGIN=",
|
||||
}
|
||||
}
|
||||
|
||||
func wrStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
|
||||
|
||||
// wrTracked writes an issue the tracker also holds — a working copy, as a pull
|
||||
// would have left it.
|
||||
func wrTracked(t *testing.T, dir, id string, number int, state string) string {
|
||||
t.Helper()
|
||||
return wrWrite(t, dir, id, fmt.Sprintf(
|
||||
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
|
||||
"origin: gitea\ngitea: %s#%d\nsynced: 2026-01-01T00:00:00Z\nurl: https://tracker.example/%s/issues/%d\n---\n"+
|
||||
"# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
|
||||
id, state, wrRepo, number, wrRepo, number, id))
|
||||
}
|
||||
|
||||
// wrLocal writes an `origin: local` issue — the only copy of that work.
|
||||
func wrLocal(t *testing.T, dir, id, state string) string {
|
||||
t.Helper()
|
||||
return wrWrite(t, dir, id, fmt.Sprintf(
|
||||
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
|
||||
"origin: local\n---\n# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
|
||||
id, state, id))
|
||||
}
|
||||
|
||||
func wrWrite(t *testing.T, dir, id, text string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(wrStore(dir), id+".md")
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func wrRead(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func wrGone(t *testing.T, path, why string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Errorf("%s is still there — %s", filepath.Base(path), why)
|
||||
}
|
||||
}
|
||||
|
||||
func wrThere(t *testing.T, path, why string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s is gone — %s", filepath.Base(path), why)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// labels
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestLabelsCreatesTheCanonicalSetAndThenChangesNothing(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
|
||||
r := runWith(t, dir, env, "", "labels")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("labels exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
|
||||
// The set is the domain's, name for name: nothing is spelled out in the
|
||||
// command, so adding a type over there is what adds it here.
|
||||
want := issue.CanonicalLabels()
|
||||
if got := tr.labelCount(); got != len(want) {
|
||||
t.Fatalf("the repository holds %d label(s), want %d:\n%s", got, len(want), r.out())
|
||||
}
|
||||
for _, name := range want {
|
||||
l := tr.label(name)
|
||||
if l == nil {
|
||||
t.Fatalf("%s was not created:\n%s", name, r.out())
|
||||
}
|
||||
if l.Color == "" {
|
||||
t.Errorf("%s was created with no colour", name)
|
||||
}
|
||||
// `exclusive` is the flag no tracker CLI could set, and the whole reason
|
||||
// label creation goes through the API.
|
||||
if !l.Exclusive {
|
||||
t.Errorf("%s is not exclusive", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(r.stdout, "created type/bug") {
|
||||
t.Errorf("the receipt does not name what it created:\n%s", r.stdout)
|
||||
}
|
||||
|
||||
// A label belongs to the repository, not to any issue: this must not have
|
||||
// touched the store.
|
||||
if _, err := os.Stat(filepath.Join(wrStore(dir), "INDEX.md")); err == nil {
|
||||
t.Error("a label bootstrap wrote into the issue store")
|
||||
}
|
||||
|
||||
mark := tr.mark()
|
||||
again := runWith(t, dir, env, "", "labels")
|
||||
if again.code != 0 {
|
||||
t.Fatalf("the second run exited %d:\n%s", again.code, again.out())
|
||||
}
|
||||
for _, c := range tr.since(mark) {
|
||||
if !strings.HasPrefix(c, "GET ") {
|
||||
t.Errorf("the second run wrote: %s", c)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(again.stdout, "present type/bug") ||
|
||||
!strings.Contains(again.stdout, "0 created") {
|
||||
t.Errorf("a second run must be a no-op and say so:\n%s", again.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// close
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestCloseChangesTheStateOnTheTrackerAndOnDisk(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Done and elsewhere", "open")
|
||||
tr.add(99, "Never seen here", "open")
|
||||
path := wrTracked(t, dir, "done-and-elsewhere", 42, "open")
|
||||
|
||||
dry := runWith(t, dir, env, "", "close", "--dry-run", "done-and-elsewhere")
|
||||
if dry.code != 0 || !strings.Contains(dry.stdout, "would close") {
|
||||
t.Fatalf("the dry run said nothing:\n%s", dry.out())
|
||||
}
|
||||
if n := tr.count("PATCH"); n != 0 {
|
||||
t.Errorf("a dry run sent %d write(s) — it must make no request at all", n)
|
||||
}
|
||||
if !strings.Contains(wrRead(t, path), "state: open") {
|
||||
t.Error("a dry run wrote to the local file")
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "close", "done-and-elsewhere")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("close exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if got := tr.state(42); got != "closed" {
|
||||
t.Errorf("the tracker says %q, want closed", got)
|
||||
}
|
||||
local := wrRead(t, path)
|
||||
if !strings.Contains(local, "state: closed") {
|
||||
t.Errorf("the local copy was not brought along:\n%s", local)
|
||||
}
|
||||
// The answer that authorized the write is also the newest thing the tracker
|
||||
// has said, so the freshness fields are stamped from it.
|
||||
if !strings.Contains(local, "remote-updated: "+wrWhen) || !strings.Contains(local, "synced: 20") {
|
||||
t.Errorf("the freshness fields were not stamped:\n%s", local)
|
||||
}
|
||||
index := filepath.Join(wrStore(dir), "INDEX.md")
|
||||
if !strings.Contains(wrRead(t, index), "closed") {
|
||||
t.Error("INDEX.md was not rebuilt from what is now on disk")
|
||||
}
|
||||
|
||||
// A number this machine has never seen: closed in the tracker, nothing
|
||||
// written here, and the receipt says which is which.
|
||||
byNumber := runWith(t, dir, env, "", "close", "99")
|
||||
if byNumber.code != 0 {
|
||||
t.Fatalf("closing by number exited %d:\n%s", byNumber.code, byNumber.out())
|
||||
}
|
||||
if got := tr.state(99); got != "closed" {
|
||||
t.Errorf("#99 says %q, want closed", got)
|
||||
}
|
||||
if !strings.Contains(byNumber.stdout, "no local copy") {
|
||||
t.Errorf("the receipt hid that there was nothing to write:\n%s", byNumber.stdout)
|
||||
}
|
||||
|
||||
// A number resolves through the file that carries the handle, so the local
|
||||
// copy of #42 is kept honest even when it was named by number.
|
||||
back := runWith(t, dir, env, "", "close", "--reopen", "42")
|
||||
if back.code != 0 {
|
||||
t.Fatalf("reopening by number exited %d:\n%s", back.code, back.out())
|
||||
}
|
||||
if got := tr.state(42); got != "open" {
|
||||
t.Errorf("#42 says %q, want open", got)
|
||||
}
|
||||
if !strings.Contains(wrRead(t, path), "state: open") {
|
||||
t.Errorf("a number named the tracker but not the local copy holding its handle:\n%s", wrRead(t, path))
|
||||
}
|
||||
|
||||
// An issue that has never left this machine has no state in the tracker to
|
||||
// change, and saying so beats editing one field of a local file.
|
||||
wrLocal(t, dir, "never-left-here", "open")
|
||||
refused := runWith(t, dir, env, "", "close", "never-left-here")
|
||||
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
|
||||
t.Errorf("closing a local issue must stop and say why:\n%s", refused.out())
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// comment
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestCommentPostsAndTheThreadLandsBesideTheIssue(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Talk about it", "open")
|
||||
wrTracked(t, dir, "talk-about-it", 42, "open")
|
||||
wrLocal(t, dir, "never-left-here", "open")
|
||||
|
||||
const said = "готово, задеплоено"
|
||||
r := runWith(t, dir, env, "", "comment", "talk-about-it", "--body", said)
|
||||
if r.code != 0 {
|
||||
t.Fatalf("comment exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
thread := tr.thread(42)
|
||||
if len(thread) != 1 || thread[0].Body != said {
|
||||
t.Fatalf("the tracker holds %v", thread)
|
||||
}
|
||||
|
||||
sidecar := filepath.Join(wrStore(dir), "talk-about-it.comments.md")
|
||||
got := wrRead(t, sidecar)
|
||||
if !strings.Contains(got, said) || !strings.Contains(got, "## comment ") {
|
||||
t.Errorf("the thread did not land beside the issue:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(r.stdout, "posted comment") || !strings.Contains(r.stdout, "thread:") {
|
||||
t.Errorf("the receipt does not say what happened:\n%s", r.stdout)
|
||||
}
|
||||
|
||||
// The target is a local id resolved through the `gitea:` handle, so an issue
|
||||
// that carries none cannot be commented on at all.
|
||||
refused := runWith(t, dir, env, "", "comment", "never-left-here", "--body", "x")
|
||||
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
|
||||
t.Errorf("commenting on a local-only issue must stop and say why:\n%s", refused.out())
|
||||
}
|
||||
if n := tr.count("POST"); n != 1 {
|
||||
t.Errorf("%d comment(s) went out, want 1 — the refused one was sent anyway", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// sync-evict
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// The one thing this command adds to the offline evict: a `state:` that is not
|
||||
// stale. The file says open, the tracker says closed, and the tracker is right.
|
||||
func TestSyncEvictRefreshesTheStateBeforeItDecides(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Closed in the web ui", "closed")
|
||||
path := wrTracked(t, dir, "closed-in-the-web-ui", 42, "open")
|
||||
|
||||
offline := runWith(t, dir, env, "", "evict")
|
||||
if offline.code != 0 || !strings.Contains(offline.stdout, "0 issue(s) evicted") {
|
||||
t.Fatalf("the offline evict must keep an issue whose file reads open:\n%s", offline.out())
|
||||
}
|
||||
wrThere(t, path, "the offline evict asks the file, and the file says open")
|
||||
|
||||
// A dry run asks, reports, and touches nothing.
|
||||
dry := runWith(t, dir, env, "", "sync-evict", "--dry-run")
|
||||
if dry.code != 0 || !strings.Contains(dry.stdout, "would evict") {
|
||||
t.Fatalf("the dry run said nothing:\n%s", dry.out())
|
||||
}
|
||||
wrThere(t, path, "a dry run deleted the issue")
|
||||
if !strings.Contains(wrRead(t, path), "state: open") {
|
||||
t.Error("a dry run wrote the refreshed state to disk")
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "sync-evict")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "open -> closed") {
|
||||
t.Errorf("the refresh was not reported:\n%s", r.stdout)
|
||||
}
|
||||
wrGone(t, path, "the tracker said it was closed")
|
||||
if index := wrRead(t, filepath.Join(wrStore(dir), "INDEX.md")); strings.Contains(index, "closed-in-the-web-ui") {
|
||||
t.Errorf("INDEX.md still lists the evicted issue:\n%s", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncEvictKeepsALocalIssueTheTrackerNeverHeardOf(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Done elsewhere", "closed")
|
||||
tracked := wrTracked(t, dir, "done-elsewhere", 42, "closed")
|
||||
local := wrLocal(t, dir, "only-copy-there-is", "closed")
|
||||
|
||||
r := runWith(t, dir, env, "", "sync-evict")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
wrThere(t, local, "a closed origin: local issue was deleted, and that file IS the work")
|
||||
wrGone(t, tracked, "it is closed and the tracker has it")
|
||||
// It was never asked about either: an issue that has never left this machine
|
||||
// is not a question the tracker has an answer to.
|
||||
if n := tr.count("GET"); n != 1 {
|
||||
t.Errorf("%d issue(s) were asked about, want 1", n)
|
||||
}
|
||||
|
||||
// Naming it explicitly does not make deleting it safe, and the reason is
|
||||
// said out loud rather than left to be inferred from silence.
|
||||
named := runWith(t, dir, env, "", "sync-evict", "only-copy-there-is")
|
||||
if named.code != 0 {
|
||||
t.Fatalf("naming a local issue exited %d:\n%s", named.code, named.out())
|
||||
}
|
||||
if !strings.Contains(named.stdout, "kept") || !strings.Contains(named.stdout, "IS the issue") {
|
||||
t.Errorf("keeping it must be said out loud:\n%s", named.out())
|
||||
}
|
||||
wrThere(t, local, "naming it on the command line deleted it")
|
||||
}
|
||||
|
||||
// A failed answer evicts NOTHING AT ALL — not even the issues whose answers had
|
||||
// already arrived. There is no ordering constraint between evictions, so there
|
||||
// is no reason to start before every answer is in.
|
||||
func TestATrackerFailureDuringSyncEvictEvictsNothing(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "First answer", "closed")
|
||||
tr.add(43, "Second answer", "closed")
|
||||
tr.breaks(43)
|
||||
answered := wrTracked(t, dir, "aaa-answered", 42, "closed")
|
||||
unanswered := wrTracked(t, dir, "bbb-unanswered", 43, "closed")
|
||||
|
||||
r := runWith(t, dir, env, "", "sync-evict")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("a tracker failure must stop the run:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, "Nothing was evicted") {
|
||||
t.Errorf("the failure must say what it did not do:\n%s", r.stderr)
|
||||
}
|
||||
wrThere(t, answered, "its answer arrived, but another one did not")
|
||||
wrThere(t, unanswered, "the tracker never answered for it")
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "tree",
|
||||
Group: GroupIssue,
|
||||
Args: "[<id>…]",
|
||||
Short: "draw the dependency graph of the local store",
|
||||
Long: `Edges come from the ` + "`depends:`" + ` metadata, which is the authoritative edge list;
|
||||
prose in the body is never walked. Because the graph is slugs all the way down,
|
||||
this works identically for issues that were never pushed anywhere.
|
||||
|
||||
Downwards is what this draws — what an issue depends on. The other direction is
|
||||
a grep, not a flag:
|
||||
|
||||
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md`,
|
||||
Examples: []Example{
|
||||
{"kettle tree", "every root (nothing depends on it)"},
|
||||
{"kettle tree wire-sqlc-appclick", "one subtree"},
|
||||
{"kettle tree --depth 2 --write", "shallow, and saved beside the issues"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
depth := fs.Int("depth", 6, "maximum depth")
|
||||
write := fs.Bool("write", false, "also write <store>/tree-<slug>.md")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issue.StoreError(root); err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
edges := issue.Graph(issues)
|
||||
|
||||
roots := args
|
||||
for _, r := range roots {
|
||||
if _, ok := issues[r]; !ok {
|
||||
return Fail("no issue %q in %s", r, root)
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
dependedOn := map[string]bool{}
|
||||
for _, deps := range edges {
|
||||
for _, d := range deps {
|
||||
dependedOn[d] = true
|
||||
}
|
||||
}
|
||||
for id := range issues {
|
||||
if !dependedOn[id] {
|
||||
roots = append(roots, id)
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 { // every issue is somebody's dependency
|
||||
for id := range issues {
|
||||
roots = append(roots, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(roots)
|
||||
}
|
||||
|
||||
text := renderTree(roots, issues, edges, *depth)
|
||||
fmt.Print(text)
|
||||
if *write {
|
||||
slug := "all"
|
||||
if len(roots) == 1 {
|
||||
slug = roots[0]
|
||||
}
|
||||
path := filepath.Join(root, "tree-"+slug+".md")
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("written: %s\n", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func renderTree(roots []string, issues map[string]*issue.Issue, edges map[string][]string, depth int) string {
|
||||
var lines []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
var walk func(id, prefix string, isLast, isRoot bool, level int)
|
||||
walk = func(id, prefix string, isLast, isRoot bool, level int) {
|
||||
connector := ""
|
||||
if !isRoot {
|
||||
connector = "├── "
|
||||
if isLast {
|
||||
connector = "└── "
|
||||
}
|
||||
}
|
||||
lines = append(lines, prefix+connector+treeLabel(id, issues, seen, edges))
|
||||
if seen[id] || level >= depth {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
kids := edges[id]
|
||||
childPrefix := prefix
|
||||
if !isRoot {
|
||||
childPrefix = prefix + "│ "
|
||||
if isLast {
|
||||
childPrefix = prefix + " "
|
||||
}
|
||||
}
|
||||
for i, k := range kids {
|
||||
walk(k, childPrefix, i == len(kids)-1, false, level+1)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range roots {
|
||||
if seen[r] {
|
||||
continue // already drawn as somebody's child — one tree, not two
|
||||
}
|
||||
walk(r, "", true, true, 0)
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
head := fmt.Sprintf("%d root(s)", len(roots))
|
||||
if len(roots) == 1 {
|
||||
head = roots[0]
|
||||
}
|
||||
out := fmt.Sprintf("# Dependency tree — %s\n\n```\n%s```\n", head, strings.Join(lines, "\n"))
|
||||
if cycles := issue.FindCycles(edges); len(cycles) > 0 {
|
||||
out += "\n## Cycles\n\n"
|
||||
for _, c := range cycles {
|
||||
out += "- " + strings.Join(c, " -> ") + "\n"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func treeLabel(id string, issues map[string]*issue.Issue, seen map[string]bool, edges map[string][]string) string {
|
||||
i, ok := issues[id]
|
||||
if !ok {
|
||||
return id + " (not in the store)"
|
||||
}
|
||||
tail := ""
|
||||
if seen[id] && len(edges[id]) > 0 {
|
||||
tail = " (see above)"
|
||||
}
|
||||
typ := i.Type()
|
||||
if typ == "" {
|
||||
typ = "-"
|
||||
}
|
||||
return fmt.Sprintf("%s [%s] %s — %s %s.md%s", id, typ, i.Title, i.State, id, tail)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// Package config holds the two files kettle reads: what this project is, and
|
||||
// who this machine is.
|
||||
//
|
||||
// The split is the whole design. `<project>/.kettle/config.yaml` says which
|
||||
// tracker repository the issues belong to and which login to reach it under —
|
||||
// facts about the project, written by `kettle init`. The credentials themselves
|
||||
// live in one file per machine, outside any repository, mode 0600.
|
||||
//
|
||||
// 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 is a secret that has to be rotated. So the project pins a login by
|
||||
// NAME and the name is worth nothing on its own.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// Environment overrides, each winning over the file it shadows. They exist for
|
||||
// CI, for a one-off run against another instance, and for anyone who would
|
||||
// rather not have a token on disk at all.
|
||||
const (
|
||||
EnvLogin = "KETTLE_LOGIN"
|
||||
EnvURL = "KETTLE_URL"
|
||||
EnvToken = "KETTLE_TOKEN"
|
||||
EnvRepo = "KETTLE_REPO"
|
||||
// EnvHome relocates the machine-wide login file; the test suite sets it so
|
||||
// a run can never read or write the developer's own.
|
||||
EnvHome = "KETTLE_CONFIG_HOME"
|
||||
)
|
||||
|
||||
const projectHeader = `# kettle — project configuration
|
||||
#
|
||||
# login the name of a login in the machine-wide file, NOT a credential.
|
||||
# Manage those with ` + "`kettle auth`" + `; they live outside this tree.
|
||||
# repo the tracker repository these issues belong to, as owner/name.
|
||||
#
|
||||
# Overrides, when you need one: ` + EnvLogin + `, ` + EnvRepo + `, ` + EnvURL + `, ` + EnvToken + `.
|
||||
`
|
||||
|
||||
// Project is `<project>/.kettle/config.yaml`.
|
||||
type Project struct {
|
||||
// Login names an entry in the machine-wide login file. Never a token.
|
||||
Login string `yaml:"login"`
|
||||
// Repo is the tracker repository, as owner/name.
|
||||
Repo string `yaml:"repo"`
|
||||
}
|
||||
|
||||
// Login is one set of credentials for one Gitea instance.
|
||||
type Login struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
// Logins is the machine-wide file.
|
||||
type Logins struct {
|
||||
Logins []Login `yaml:"logins"`
|
||||
}
|
||||
|
||||
// ErrNoConfig means the project has no config.yaml yet.
|
||||
var ErrNoConfig = errors.New("no project configuration")
|
||||
|
||||
// ProjectPath is where this project's config.yaml is, or "" with no project.
|
||||
func ProjectPath(start string) string { return project.ConfigPath(start) }
|
||||
|
||||
// LoadProject reads the project configuration.
|
||||
//
|
||||
// A missing file is ErrNoConfig, not an empty config: "this project has not
|
||||
// been told which tracker it belongs to" and "it belongs to no tracker" are
|
||||
// different answers and only one of them is fixable by running init.
|
||||
func LoadProject(start string) (*Project, error) {
|
||||
path := ProjectPath(start)
|
||||
if path == "" {
|
||||
return nil, project.NotFoundError(start)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w at %s — run `kettle init` there", ErrNoConfig, path)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p Project
|
||||
if err := strictUnmarshal(raw, &p); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ReadProjectFile reads a config.yaml at a path already known, reporting
|
||||
// whether the file was there.
|
||||
//
|
||||
// LoadProject resolves the path by walking for a marker, which is the right
|
||||
// thing everywhere except inside `kettle init` — the command that is creating
|
||||
// the marker, and on a dry run may not have created it at all.
|
||||
func ReadProjectFile(path string) (*Project, bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return &Project{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var p Project
|
||||
if err := strictUnmarshal(raw, &p); err != nil {
|
||||
return nil, true, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &p, true, nil
|
||||
}
|
||||
|
||||
// SaveProject writes the project configuration, header comment and all.
|
||||
func SaveProject(path string, p *Project) error {
|
||||
body, err := yaml.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append([]byte(projectHeader+"\n"), body...), 0o644)
|
||||
}
|
||||
|
||||
// LoginsPath is the machine-wide login file.
|
||||
//
|
||||
// One file per machine, deliberately outside every working tree: which tokens
|
||||
// this computer holds is a fact about the computer, the way which issues a tree
|
||||
// holds is a fact about the tree.
|
||||
func LoginsPath() string {
|
||||
if h := os.Getenv(EnvHome); h != "" {
|
||||
return filepath.Join(h, "logins.yaml")
|
||||
}
|
||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
||||
return filepath.Join(x, "kettle", "logins.yaml")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "kettle", "logins.yaml")
|
||||
}
|
||||
|
||||
// LoadLogins reads the machine-wide login file. A missing file is an empty
|
||||
// list, not an error: a machine with no logins yet is an ordinary machine.
|
||||
func LoadLogins() (*Logins, error) {
|
||||
path := LoginsPath()
|
||||
if path == "" {
|
||||
return &Logins{}, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return &Logins{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var l Logins
|
||||
if err := strictUnmarshal(raw, &l); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// SaveLogins writes the machine-wide login file with 0600, and creates its
|
||||
// directory with 0700. The file holds bearer tokens; nothing else on the
|
||||
// machine has any business reading it.
|
||||
func SaveLogins(l *Logins) error {
|
||||
path := LoginsPath()
|
||||
if path == "" {
|
||||
return errors.New("cannot locate a home directory for the login file — set " + EnvHome)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := yaml.Marshal(l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, body, 0o600)
|
||||
}
|
||||
|
||||
// Find returns the login with this name.
|
||||
func (l *Logins) Find(name string) *Login {
|
||||
for i := range l.Logins {
|
||||
if l.Logins[i].Name == name {
|
||||
return &l.Logins[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Names lists every login on this machine, for an error message that can
|
||||
// actually be acted on.
|
||||
func (l *Logins) Names() []string {
|
||||
out := make([]string, 0, len(l.Logins))
|
||||
for _, e := range l.Logins {
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Resolved is everything the transport needs, with every override applied.
|
||||
type Resolved struct {
|
||||
Login string
|
||||
URL string
|
||||
Token string
|
||||
Owner string
|
||||
Repo string
|
||||
}
|
||||
|
||||
// Slug is owner/name, the way a tracker writes it.
|
||||
func (r *Resolved) Slug() string { return r.Owner + "/" + r.Repo }
|
||||
|
||||
// Redacted is the same thing with the token replaced, for printing.
|
||||
func (r *Resolved) Redacted() Resolved {
|
||||
out := *r
|
||||
if out.Token != "" {
|
||||
out.Token = "(set)"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Resolve merges the project config, the machine's login file, and the
|
||||
// environment into what the transport needs.
|
||||
//
|
||||
// Every failure names the file it read and the command that fixes it. "401
|
||||
// Unauthorized" is what happens when this function is allowed to return a
|
||||
// half-filled struct.
|
||||
func Resolve(start string) (*Resolved, error) {
|
||||
var p Project
|
||||
if loaded, err := LoadProject(start); err == nil {
|
||||
p = *loaded
|
||||
} else if !errors.Is(err, ErrNoConfig) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &Resolved{Login: p.Login}
|
||||
if v := os.Getenv(EnvLogin); v != "" {
|
||||
out.Login = v
|
||||
}
|
||||
|
||||
repo := p.Repo
|
||||
if v := os.Getenv(EnvRepo); v != "" {
|
||||
repo = v
|
||||
}
|
||||
if repo != "" {
|
||||
owner, name, ok := strings.Cut(repo, "/")
|
||||
if !ok || owner == "" || name == "" {
|
||||
return nil, fmt.Errorf("repo %q is not owner/name", repo)
|
||||
}
|
||||
out.Owner, out.Repo = owner, name
|
||||
}
|
||||
|
||||
if out.Login != "" {
|
||||
logins, err := LoadLogins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := logins.Find(out.Login)
|
||||
if entry == nil {
|
||||
known := "none on this machine"
|
||||
if names := logins.Names(); len(names) > 0 {
|
||||
known = strings.Join(names, ", ")
|
||||
}
|
||||
return nil, fmt.Errorf("no login %q in %s — known: %s; add one with `kettle auth add`",
|
||||
out.Login, LoginsPath(), known)
|
||||
}
|
||||
out.URL, out.Token = entry.URL, entry.Token
|
||||
}
|
||||
|
||||
if v := os.Getenv(EnvURL); v != "" {
|
||||
out.URL = v
|
||||
}
|
||||
if v := os.Getenv(EnvToken); v != "" {
|
||||
out.Token = v
|
||||
}
|
||||
out.URL = strings.TrimRight(out.URL, "/")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Require is Resolve plus the assertion that the result can actually reach a
|
||||
// tracker.
|
||||
func Require(start string) (*Resolved, error) {
|
||||
r, err := Resolve(start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var missing []string
|
||||
if r.URL == "" {
|
||||
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")")
|
||||
}
|
||||
if r.Token == "" {
|
||||
missing = append(missing, "a token (`kettle auth add`, or set "+EnvToken+")")
|
||||
}
|
||||
if r.Owner == "" {
|
||||
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// strictUnmarshal refuses keys the struct does not know.
|
||||
//
|
||||
// The alternative is silence: an older binary reading a newer config would drop
|
||||
// the setting it did not recognize, and rewriting the file would delete it.
|
||||
// Being told "unknown field" beats finding out later.
|
||||
func strictUnmarshal(raw []byte, out any) error {
|
||||
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(out); err != nil && err.Error() != "EOF" {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// Package gitea is the transport: everything that talks to a tracker, and
|
||||
// nothing else.
|
||||
//
|
||||
// It knows numbers, logins, HTTP verbs, pagination and JSON. It does not know
|
||||
// what an issue IS — no sections, no acceptance criteria, no type taxonomy —
|
||||
// and the import graph says so in both directions: this package may not reach
|
||||
// into internal/issue, and internal/issue may not reach in here. A tracker
|
||||
// number is not a domain concept and a checkbox is not a transport one.
|
||||
// Translating between the two is a layer of its own — internal/mapping — and
|
||||
// that layer is not imported here either: it sits above this package, not
|
||||
// beside it.
|
||||
//
|
||||
// The JSON shapes and the issue keys are internal/wire's. They are not this
|
||||
// package's to own, because the bridge needs exactly the same vocabulary and
|
||||
// cannot import a transport to get it; a copy on each side is two structs that
|
||||
// drift and a command that copies fields between them by hand.
|
||||
//
|
||||
// Every request goes through Call. One place sets the header, one place reads
|
||||
// a status code, one place files the request body. When this was a Python
|
||||
// module shelling out to `tea api`, "why did that fail" meant reading a
|
||||
// subprocess's stderr and guessing; here a failure is an *APIError carrying the
|
||||
// status AND the body the server actually sent, because "500" on its own has
|
||||
// never helped anybody.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
const (
|
||||
// apiPrefix is where every Gitea instance puts its REST API.
|
||||
apiPrefix = "/api/v1"
|
||||
// userAgent names this binary in the server's log. A tracker admin looking
|
||||
// at a burst of requests should be able to tell what made them.
|
||||
userAgent = "kettle"
|
||||
// requestTimeout bounds a single call. A hung tracker must not hang a push
|
||||
// half way through a set of issues.
|
||||
requestTimeout = 30 * time.Second
|
||||
// maxErrorBody caps what an error quotes back. A server having a bad day
|
||||
// answers with an HTML page, and an error message is not a place to paste
|
||||
// one.
|
||||
maxErrorBody = 2000
|
||||
)
|
||||
|
||||
// Client talks to one repository on one Gitea instance.
|
||||
type Client struct {
|
||||
// HTTP is the transport, exported so a caller can change the timeout or
|
||||
// hand in an instrumented one. Never nil after New.
|
||||
HTTP *http.Client
|
||||
|
||||
base string // instance URL with the API prefix, no trailing slash
|
||||
token string
|
||||
repo wire.Repo
|
||||
|
||||
// payloadRoot is resolved once, by New, and is never taken from a caller.
|
||||
// The one time where a request body lands was an argument, it got pointed
|
||||
// at the issue store — see writePayload.
|
||||
payloadRoot string
|
||||
}
|
||||
|
||||
// New builds a client for the repository this project points at.
|
||||
//
|
||||
// It refuses a half-filled configuration instead of letting the first call come
|
||||
// back 401 or 404: those answers name nothing an operator can act on, and every
|
||||
// field missing here has exactly one command that supplies it.
|
||||
func New(cfg *config.Resolved) (*Client, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("no resolved configuration — call config.Require first")
|
||||
}
|
||||
var missing []string
|
||||
if cfg.URL == "" {
|
||||
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+config.EnvURL+")")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
missing = append(missing, "a token (`kettle auth add`, or set "+config.EnvToken+")")
|
||||
}
|
||||
if cfg.Owner == "" || cfg.Repo == "" {
|
||||
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+config.EnvRepo+")")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
|
||||
}
|
||||
return &Client{
|
||||
HTTP: &http.Client{Timeout: requestTimeout},
|
||||
base: strings.TrimRight(cfg.URL, "/") + apiPrefix,
|
||||
token: cfg.Token,
|
||||
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
|
||||
payloadRoot: project.PayloadRoot(""),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Repo is the repository every path is built against.
|
||||
func (c *Client) Repo() wire.Repo { return c.repo }
|
||||
|
||||
// For returns a copy of this client pointed at another repository, for the run
|
||||
// that was given an explicit owner/name. The credentials and the scratchpad
|
||||
// come along; only the paths change.
|
||||
func (c *Client) For(r wire.Repo) *Client {
|
||||
out := *c
|
||||
out.repo = r
|
||||
return &out
|
||||
}
|
||||
|
||||
// Body is a request payload and the name its dump is filed under.
|
||||
//
|
||||
// The name is the caller's label for this call, not a path: it becomes
|
||||
// `<name>.json` in the scratchpad, and something that identifies the call in a
|
||||
// post-mortem — an issue's slug, a label's name — is worth more there than a
|
||||
// serial number.
|
||||
type Body struct {
|
||||
Name string
|
||||
Data any
|
||||
}
|
||||
|
||||
// Call makes one request and decodes the answer into out, which may be nil when
|
||||
// there is nothing to read.
|
||||
//
|
||||
// body may be nil. When it is not, its Data is marshalled once: the bytes filed
|
||||
// in the scratchpad and the bytes on the wire are the same bytes, so a retry
|
||||
// from the file sends what this call sent.
|
||||
//
|
||||
// An empty response body leaves out untouched — a 204 from a PATCH is a
|
||||
// success, not a decode failure.
|
||||
func (c *Client) Call(method, path string, body *Body, out any) error {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if payload, err = c.writePayload(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
endpoint := c.base + "/" + strings.TrimLeft(path, "/")
|
||||
var reader io.Reader
|
||||
if payload != nil {
|
||||
reader = bytes.NewReader(payload)
|
||||
}
|
||||
req, err := http.NewRequest(method, endpoint, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", method, endpoint, err)
|
||||
}
|
||||
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
|
||||
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
// The token travels in a header and never in the URL, so an error is
|
||||
// free to quote the URL in full.
|
||||
return fmt.Errorf("%s %s: %w", method, endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: reading the response: %w", method, endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(raw)}
|
||||
}
|
||||
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected (%w): %s",
|
||||
method, endpoint, resp.StatusCode, err, truncate(string(raw)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIError is a non-2xx answer, carrying both halves of what happened.
|
||||
//
|
||||
// The status on its own is not a diagnosis. Gitea answers 422 for a label that
|
||||
// already exists, for a milestone id that belongs to another repository, and
|
||||
// for a body missing a field, and the three are told apart only by the message
|
||||
// sent with them — so the body travels with the code, always.
|
||||
type APIError struct {
|
||||
Method string
|
||||
URL string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
body := strings.TrimSpace(e.Body)
|
||||
if body == "" {
|
||||
body = "(the response body was empty)"
|
||||
} else {
|
||||
body = truncate(body)
|
||||
}
|
||||
status := http.StatusText(e.Status)
|
||||
if status != "" {
|
||||
status = " " + status
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
||||
}
|
||||
|
||||
// StatusIs reports whether err is an API answer with this status code, for the
|
||||
// handful of places where one code means something specific — a 409 from a
|
||||
// dependency link that is already there, say.
|
||||
func StatusIs(err error, status int) bool {
|
||||
var apiErr *APIError
|
||||
return errors.As(err, &apiErr) && apiErr.Status == status
|
||||
}
|
||||
|
||||
func truncate(s string) string {
|
||||
if len(s) <= maxErrorBody {
|
||||
return s
|
||||
}
|
||||
cut := s[:maxErrorBody]
|
||||
// Never split a rune: a truncated message that ends in a broken byte is a
|
||||
// message a terminal renders as garbage.
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
return fmt.Sprintf("%s… (%d bytes total)", cut, len(s))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// where request bodies land
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// writePayload marshals a request body, files a copy under `.kettle/payload/`,
|
||||
// and returns the bytes to send.
|
||||
//
|
||||
// The file survives the call, for a retry or a post-mortem.
|
||||
//
|
||||
// WHERE IT LANDS IS NOT THE CALLER'S BUSINESS, and never was. The directory is
|
||||
// this package's scratchpad — a SIBLING of the issue store under the same
|
||||
// marker, resolved by the same walk, so which command wrote a body cannot
|
||||
// change where it went and the two can never end up in different projects. The
|
||||
// one time it was a caller's argument it got pointed at the store, and a label
|
||||
// bootstrap that touches no issue at all materialized an issue directory on a
|
||||
// fresh checkout: store contents are the thing being tracked, request bodies
|
||||
// are debris of the transport, and when they share a path `ls` starts lying
|
||||
// about what the project holds.
|
||||
//
|
||||
// It is created lazily, by the first write of a run and only then, so a dry run
|
||||
// or a run with nothing to send leaves no directory behind.
|
||||
func (c *Client) writePayload(b *Body) ([]byte, error) {
|
||||
if c.payloadRoot == "" {
|
||||
return nil, project.NotFoundError("")
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetIndent("", " ")
|
||||
// An issue body carries `<!-- … -->` 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...))
|
||||
}
|
||||
@@ -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 := "<!-- kettle:id wire-sqlc --> 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), "<!-- kettle:id wire-sqlc --> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": <int>, "owner": "<owner>", "repo": "<name>"}
|
||||
// "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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+` +
|
||||
`\[(?P<box>[ xX])\](?P<text>[ \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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: <tracker> the work is somewhere else too
|
||||
//
|
||||
// THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. `origin: local` means this
|
||||
// file IS the issue — there is no other copy and deleting it deletes the work.
|
||||
// It is 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ", ") + "]" }
|
||||
@@ -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
|
||||
// `<id>.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.
|
||||
//
|
||||
// `<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||
// companion another layer parked there (`<id>.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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <!-- kettle:id … -->, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
//
|
||||
// <!-- kettle:id wire-sqlc-appclick -->
|
||||
//
|
||||
// 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/<id>.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 `<!-- tea:id … -->`. 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]*(?:kettle|tea):id[ \t]+(\S+)[ \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 "<!-- kettle:id " + id + " -->" }
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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", "<!-- kettle:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
|
||||
// The whole reason the reader is wider than the writer.
|
||||
{"the spelling already in the tracker", "<!-- tea:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
|
||||
{"indented and loosely spaced", " <!-- tea:id wire-sqlc --> \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", "<!-- kettle:id Wire_SQLC -->\n", ""},
|
||||
{"not on a line of its own", "text <!-- kettle:id wire-sqlc -->\n", ""},
|
||||
{"the first valid marker wins", "<!-- tea:id first-one -->\n<!-- kettle:id second-one -->\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 = "<!-- tea:id wire-sqlc-appclick -->\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, "<!-- kettle:id wire-sqlc-appclick -->\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 := "<!-- tea:id old-one -->\n\n<!-- kettle:id other-one -->\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, "<!--") != 1 {
|
||||
t.Errorf("a second pass added one: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripIsTheExactInverseOfWith(t *testing.T) {
|
||||
bodies := []string{
|
||||
"## Summary\nx",
|
||||
"## Summary\nx\n\n## Acceptance criteria\n- [ ] один\n",
|
||||
"",
|
||||
}
|
||||
for _, b := range bodies {
|
||||
if got := StripIDMarker(WithIDMarker(b, "an-id")); got != b {
|
||||
t.Errorf("StripIDMarker(WithIDMarker(%q)) = %q", b, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The common case — an issue filed in the web UI — costs nothing and is not
|
||||
// reformatted.
|
||||
func TestStripLeavesAnUnmarkedBodyByteForByte(t *testing.T) {
|
||||
body := "\n\n## Summary\nx\n\n\n"
|
||||
if got := StripIDMarker(body); got != body {
|
||||
t.Errorf("StripIDMarker rewrote a body with no marker in it: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// domain -> Gitea.
|
||||
|
||||
// RequestOptions are what the transport resolved before the call: label names
|
||||
// are ids by then, and a milestone title is a number.
|
||||
//
|
||||
// Both are lookups against one repository, which is why they cannot be done
|
||||
// here — this package never learns which repository it is translating for
|
||||
// beyond the name it is handed.
|
||||
type RequestOptions struct {
|
||||
// LabelIDs is name -> id for the labels this repository holds. A nil map
|
||||
// leaves `labels` out of the request; a non-nil one sends the list, empty
|
||||
// included, and a label the repository does not have is silently left off
|
||||
// rather than failing the write — an unknown label is a bootstrap that has
|
||||
// not run, not a reason to lose the issue.
|
||||
LabelIDs map[string]int64
|
||||
// MilestoneID is the resolved milestone. nil leaves the key out, which on
|
||||
// an edit means "leave whatever is attached alone".
|
||||
MilestoneID *int64
|
||||
// IncludeState sends `state`. An edit that means to open or close says so;
|
||||
// a create takes the tracker's default.
|
||||
IncludeState bool
|
||||
}
|
||||
|
||||
// ToRequest is the request body for creating or editing an issue.
|
||||
//
|
||||
// The prose is sent verbatim — see the package doc on why slugs in
|
||||
// `## Depends on` are not rewritten to `#N`. The one addition is the id marker,
|
||||
// prepended (never appended) so the tracker remembers the slug after push has
|
||||
// deleted the local file. FromPayload takes it straight back off, so the body
|
||||
// still round-trips byte for byte.
|
||||
//
|
||||
// A create needs a title and a body, so those two are always filled. Every
|
||||
// other key is left out unless the caller has an opinion about it: on a PATCH
|
||||
// an absent key leaves the tracker's value alone, and a present one overwrites
|
||||
// it — see wire.IssueRequest for what each of them clears when it is sent
|
||||
// empty.
|
||||
func ToRequest(i *issue.Issue, opt RequestOptions) *wire.IssueRequest {
|
||||
r := &wire.IssueRequest{
|
||||
Title: wire.Set(i.Title),
|
||||
Body: wire.Set(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
|
||||
}
|
||||
if opt.LabelIDs != nil {
|
||||
ids := []int64{}
|
||||
for _, name := range i.Labels {
|
||||
if id, ok := opt.LabelIDs[name]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
r.Labels = &ids
|
||||
}
|
||||
// Copied, so the request body and the issue it came from cannot alias one
|
||||
// slice: whatever a caller does to either afterwards is not a change to
|
||||
// what was sent.
|
||||
if len(i.Assignees) > 0 {
|
||||
r.Assignees = wire.Set(slices.Clone(i.Assignees))
|
||||
}
|
||||
if opt.MilestoneID != nil {
|
||||
r.Milestone = opt.MilestoneID
|
||||
}
|
||||
if opt.IncludeState {
|
||||
r.State = wire.Set(i.State)
|
||||
}
|
||||
// An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
||||
// clear whatever is set on the Gitea side, so the key is left out instead.
|
||||
if branch := strings.TrimSpace(i.Extra[BranchKey]); branch != "" {
|
||||
r.Ref = wire.Set(branch)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ApplyRemote stamps the sync-owned fields onto an issue after a successful
|
||||
// write. Mutates and returns it; `origin` is the one domain field this touches,
|
||||
// and it touches it because "this work exists somewhere else now" is exactly
|
||||
// what has just become true.
|
||||
func ApplyRemote(i *issue.Issue, p *wire.Issue, repo wire.Repo, synced string) *issue.Issue {
|
||||
if i.Extra == nil {
|
||||
i.Extra = map[string]string{}
|
||||
}
|
||||
i.Origin = Origin
|
||||
i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: p.Number}.String()
|
||||
i.Extra[URLKey] = p.HTMLURL
|
||||
i.Extra[SyncedKey] = synced
|
||||
if p.UpdatedAt != "" {
|
||||
i.Extra[RemoteUpdatedKey] = p.UpdatedAt
|
||||
}
|
||||
return i
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Initializing is a statement, and the only one that matters here: *this*
|
||||
// directory is the project whose issues live in it. It is answered once, by a
|
||||
// person, and everything downstream reads the answer instead of guessing.
|
||||
//
|
||||
// The marker is deliberately something an operator makes, not something
|
||||
// inferred from the tree: `.git` is in every clone including this repository's
|
||||
// own, so a plugin that inferred its root from one wrote issues into itself.
|
||||
|
||||
// Layouts this has been through, migrated in on init in the order listed —
|
||||
// oldest first, so a tree that skipped a generation still lands in one place.
|
||||
//
|
||||
// Each is a move, never a copy: two stores is the state the marker exists to
|
||||
// prevent, and a store left behind at an old path is a store somebody will edit
|
||||
// by accident months later.
|
||||
var legacy = map[string][]string{
|
||||
"issues": {
|
||||
filepath.Join("tmp", "issues"),
|
||||
filepath.Join(".tea", "issues"),
|
||||
},
|
||||
"payload": {
|
||||
filepath.Join("tmp", "payload"),
|
||||
filepath.Join(".tea", "payload"),
|
||||
},
|
||||
}
|
||||
|
||||
// ClashError reports that a migration found the same name on both sides.
|
||||
//
|
||||
// Two versions of one issue, and which one survives is not a decision a
|
||||
// migration gets to make quietly.
|
||||
type ClashError struct {
|
||||
Src, Dst string
|
||||
Names []string
|
||||
}
|
||||
|
||||
func (e *ClashError) Error() string {
|
||||
names := e.Names
|
||||
suffix := ""
|
||||
if len(names) > 5 {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(names)-5)
|
||||
names = names[:5]
|
||||
}
|
||||
return fmt.Sprintf("%s and %s both hold %s%s — move or delete one side first; nothing was changed",
|
||||
e.Src, e.Dst, strings.Join(names, ", "), suffix)
|
||||
}
|
||||
|
||||
// Init makes root a project. Everything it does is idempotent:
|
||||
//
|
||||
// - creates .tea/issues/ and .tea/payload/
|
||||
// - moves an existing tmp/issues/ and tmp/payload/ in, if it finds them
|
||||
// - adds .tea/ to .gitignore
|
||||
//
|
||||
// The move is the migration off the old layout and it is a move, not a copy:
|
||||
// two stores is the state the marker exists to prevent, and a store left behind
|
||||
// at the old path is a store somebody will edit by accident.
|
||||
//
|
||||
// .tea/ is gitignored because an `origin: local` issue is the only copy of that
|
||||
// work and the operator, not this command, decides what goes in a shared
|
||||
// history. Committing the store is a legitimate choice — drop the line if you
|
||||
// make it.
|
||||
//
|
||||
// Returns one line per thing done, for the receipt.
|
||||
func Init(root string, dryRun bool) ([]string, error) {
|
||||
var done []string
|
||||
marker := filepath.Join(root, Marker)
|
||||
fresh := !isDir(marker)
|
||||
|
||||
for _, name := range []string{"issues", "payload"} {
|
||||
d := filepath.Join(marker, name)
|
||||
if isDir(d) {
|
||||
continue
|
||||
}
|
||||
if !dryRun {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return done, err
|
||||
}
|
||||
}
|
||||
done = append(done, "created "+filepath.Join(Marker, name))
|
||||
}
|
||||
|
||||
for _, name := range []string{"issues", "payload"} {
|
||||
for _, old := range legacy[name] {
|
||||
src := filepath.Join(root, old)
|
||||
moved, err := migrate(src, filepath.Join(marker, name), dryRun)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
switch {
|
||||
case moved == nil:
|
||||
// nothing there to migrate
|
||||
case len(moved) == 0:
|
||||
done = append(done, old+" was empty — nothing to move")
|
||||
default:
|
||||
done = append(done, fmt.Sprintf("moved %d file(s) from %s to %s",
|
||||
len(moved), old, filepath.Join(Marker, name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
// The old marker goes only when the migration emptied it — anything else
|
||||
// parked in there is somebody's, and this is not the command that decides
|
||||
// what.
|
||||
if !dryRun {
|
||||
os.Remove(filepath.Join(root, ".tea"))
|
||||
}
|
||||
|
||||
added, err := addToGitignore(filepath.Join(root, ".gitignore"), Marker+"/", dryRun)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
if added {
|
||||
done = append(done, "added "+Marker+"/ to .gitignore")
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(done) == 0:
|
||||
done = append(done, "already initialized — nothing to do")
|
||||
case fresh:
|
||||
done = append(done, fmt.Sprintf("%s now tracks issues in %s/issues", root, Marker))
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// migrate moves the CONTENTS of src into dst — contents, not the directory, so
|
||||
// an already-created destination is not a reason to refuse. Returns the names
|
||||
// moved, or nil when there was nothing to migrate.
|
||||
func migrate(src, dst string, dryRun bool) ([]string, error) {
|
||||
if !isDir(src) {
|
||||
return nil, nil
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
var clashes []string
|
||||
for _, n := range names {
|
||||
if _, err := os.Lstat(filepath.Join(dst, n)); err == nil {
|
||||
clashes = append(clashes, n)
|
||||
}
|
||||
}
|
||||
if len(clashes) > 0 {
|
||||
return nil, &ClashError{Src: src, Dst: dst, Names: clashes}
|
||||
}
|
||||
if dryRun {
|
||||
return names, nil
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := os.Rename(filepath.Join(src, n), filepath.Join(dst, n)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
os.Remove(src) // only succeeds when we emptied it, which is the intent
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// addToGitignore appends entry unless some line already ignores it.
|
||||
func addToGitignore(path, entry string, dryRun bool) (bool, error) {
|
||||
var lines []string
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
lines = strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
want := strings.TrimSuffix(entry, "/")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSuffix(strings.TrimSpace(line), "/") == want {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if dryRun {
|
||||
return true, nil
|
||||
}
|
||||
trailer := "\n"
|
||||
if len(lines) == 0 || lines[len(lines)-1] == "" {
|
||||
trailer = ""
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(trailer + entry + "\n"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Package project answers one question: which directory is the project.
|
||||
//
|
||||
// Everything that is a fact about the project — the issue store, the request
|
||||
// payload scratchpad, the tracker the issues belong to — is resolved from the
|
||||
// answer, and the answer is found by one walk written once. The guard, the
|
||||
// transport and the store used to each have their own copy of that walk in
|
||||
// Python, and they disagreed: in a linked worktree `tea` worked while every
|
||||
// script reported no login pinned.
|
||||
//
|
||||
// This package depends on nothing but the standard library, and nothing in it
|
||||
// resolves from the binary's own location. Where an installation keeps its
|
||||
// files is a fact about the installation; which 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.
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Marker is the directory an operator creates to state "this is a project".
|
||||
// It is never inferred. `.git` was tried and is in every clone, including this
|
||||
// repository's own, so a plugin resolved its store inside itself.
|
||||
const Marker = ".kettle"
|
||||
|
||||
// Everything under the marker, each resolved by the same walk so that which
|
||||
// command wrote a file cannot change where it landed.
|
||||
var (
|
||||
storeParts = []string{Marker, "issues"}
|
||||
payloadParts = []string{Marker, "payload"}
|
||||
configParts = []string{Marker, "config.yaml"}
|
||||
)
|
||||
|
||||
// Anchors are the directories a root search starts from, in order, first hit
|
||||
// wins: the project the agent harness was opened on, then the working
|
||||
// directory. A non-empty start overrides both and exists so resolution can be
|
||||
// exercised against a scratch tree.
|
||||
func Anchors(start string) []string {
|
||||
if start != "" {
|
||||
abs, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{abs}
|
||||
}
|
||||
var out []string
|
||||
for _, d := range []string{os.Getenv("CLAUDE_PROJECT_DIR"), cwd()} {
|
||||
if d == "" || !isDir(d) {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !contains(out, abs) {
|
||||
out = append(out, abs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Parents yields start and every ancestor of it, up to the filesystem root.
|
||||
func Parents(start string) []string {
|
||||
d, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
out = append(out, d)
|
||||
parent := filepath.Dir(d)
|
||||
if parent == d {
|
||||
return out
|
||||
}
|
||||
d = parent
|
||||
}
|
||||
}
|
||||
|
||||
// GitDirOf is the private git directory `d/.git` points at, or "".
|
||||
//
|
||||
// Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory
|
||||
// and there is nothing to follow.
|
||||
func GitDirOf(d string) string {
|
||||
p := filepath.Join(d, ".git")
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil || fi.IsDir() {
|
||||
return ""
|
||||
}
|
||||
head, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(head), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
target, ok := strings.CutPrefix(line, "gitdir:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(d, target)
|
||||
}
|
||||
abs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return abs
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MainWorktree is the main working tree of d's repository when d is a linked
|
||||
// worktree, or "".
|
||||
//
|
||||
// `<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir` file
|
||||
// holds a path to `<main>/.git`; the main working tree is its parent. The
|
||||
// `.git` basename check keeps this to worktrees: a submodule's `.git` is a
|
||||
// pointer too, but it points into `<super>/.git/modules/…`, and the tree it
|
||||
// belongs to is already on the parent chain.
|
||||
func MainWorktree(d string) string {
|
||||
gitdir := GitDirOf(d)
|
||||
if gitdir == "" || !isDir(gitdir) {
|
||||
return ""
|
||||
}
|
||||
common := gitdir
|
||||
if raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")); err == nil {
|
||||
if rel := strings.TrimSpace(string(raw)); rel != "" {
|
||||
if abs, err := filepath.Abs(filepath.Join(gitdir, rel)); err == nil {
|
||||
common = abs
|
||||
}
|
||||
}
|
||||
}
|
||||
if filepath.Base(common) != ".git" {
|
||||
return ""
|
||||
}
|
||||
root := filepath.Dir(common)
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if root != "" && isDir(root) && root != abs {
|
||||
return root
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Root is the nearest ancestor of an anchor (inclusive) holding the marker, or
|
||||
// "" when there is no project.
|
||||
//
|
||||
// A marker, not a fixed number of `..` hops: how deep a caller sits below the
|
||||
// root is an implementation detail of the layout, and the layout is not a
|
||||
// promise. Walking up means every command sees one store from anywhere inside
|
||||
// the project — including from inside the store itself — while a cd into a
|
||||
// DIFFERENT project correctly answers with that project's store.
|
||||
//
|
||||
// A linked worktree is the same project on another branch, and the marker is
|
||||
// gitignored, so it is only ever in the main checkout: the chain is searched
|
||||
// first and always wins, then the main working tree of any worktree met on it.
|
||||
func Root(start string) string {
|
||||
for _, anchor := range Anchors(start) {
|
||||
var hops []string
|
||||
for _, d := range Parents(anchor) {
|
||||
if isDir(filepath.Join(d, Marker)) {
|
||||
return d
|
||||
}
|
||||
if main := MainWorktree(d); main != "" && !contains(hops, main) {
|
||||
hops = append(hops, main)
|
||||
}
|
||||
}
|
||||
// One level of indirection, never two: a main checkout is not itself a
|
||||
// linked worktree, so this cannot chain and cannot cycle.
|
||||
for _, hop := range hops {
|
||||
for _, d := range Parents(hop) {
|
||||
if isDir(filepath.Join(d, Marker)) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StoreRoot is the absolute path of the issue store, or "" with no project.
|
||||
func StoreRoot(start string) string { return under(start, storeParts) }
|
||||
|
||||
// PayloadRoot is the absolute path of the request-payload scratchpad, or "".
|
||||
//
|
||||
// A sibling of the store under the same marker, resolved by the same walk, so
|
||||
// the scratchpad and the store can never end up in two different projects —
|
||||
// and so a scratchpad can never sit INSIDE a store, where a call that touched
|
||||
// no issue would still materialize the issue directory.
|
||||
func PayloadRoot(start string) string { return under(start, payloadParts) }
|
||||
|
||||
// ConfigPath is the absolute path of the project's tracker config, or "".
|
||||
func ConfigPath(start string) string { return under(start, configParts) }
|
||||
|
||||
func under(start string, parts []string) string {
|
||||
root := Root(start)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(append([]string{root}, parts...)...)
|
||||
}
|
||||
|
||||
// NotFoundError explains why no project could be resolved, naming every
|
||||
// directory the search began from.
|
||||
//
|
||||
// The anchors, not the whole chain above them: an operator who sees the two
|
||||
// places the search started knows immediately whether it started where they
|
||||
// meant it to.
|
||||
func NotFoundError(start string) error {
|
||||
dirs := strings.Join(Anchors(start), " and ")
|
||||
if dirs == "" {
|
||||
dirs = "nowhere"
|
||||
}
|
||||
return fmt.Errorf("no %s/ found — searched up from %s. Run `kettle init` in the project you mean to track issues in", Marker, dirs)
|
||||
}
|
||||
|
||||
func isDir(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
func cwd() string {
|
||||
d, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every test here strips CLAUDE_PROJECT_DIR: it is the first anchor of the
|
||||
// walk, so the harness's own value would point every fixture at whatever
|
||||
// repository the suite happens to run in.
|
||||
func fixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", "")
|
||||
dir := t.TempDir()
|
||||
// macOS hands out /var/… , a symlink to /private/var, and the walk works
|
||||
// in resolved paths.
|
||||
real, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return real
|
||||
}
|
||||
|
||||
func chdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Chdir(dir)
|
||||
}
|
||||
|
||||
func TestRootFindsTheNearestMarkerUpFromTheWorkingDirectory(t *testing.T) {
|
||||
root := fixture(t)
|
||||
deep := filepath.Join(root, "a", "b", "c")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, Marker), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chdir(t, deep)
|
||||
|
||||
if got := Root(""); got != root {
|
||||
t.Errorf("Root() = %q, want %q", got, root)
|
||||
}
|
||||
if got := StoreRoot(""); got != filepath.Join(root, Marker, "issues") {
|
||||
t.Errorf("StoreRoot() = %q", got)
|
||||
}
|
||||
// The scratchpad is a sibling of the store, never inside it: a call that
|
||||
// touches no issue must not materialize the issue directory.
|
||||
if got := PayloadRoot(""); got != filepath.Join(root, Marker, "payload") {
|
||||
t.Errorf("PayloadRoot() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMarkerIsAnAnswerNotAFallback(t *testing.T) {
|
||||
dir := fixture(t)
|
||||
chdir(t, dir)
|
||||
|
||||
if got := Root(""); got != "" {
|
||||
t.Errorf("Root() = %q, want empty — a plausible-looking directory is the failure this replaces", got)
|
||||
}
|
||||
if got := StoreRoot(""); got != "" {
|
||||
t.Errorf("StoreRoot() = %q, want empty", got)
|
||||
}
|
||||
if err := NotFoundError(""); err == nil {
|
||||
t.Fatal("NotFoundError must explain itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNearestMarkerWinsOverAnAncestorOne(t *testing.T) {
|
||||
outer := fixture(t)
|
||||
inner := filepath.Join(outer, "vendored")
|
||||
for _, d := range []string{filepath.Join(outer, Marker), filepath.Join(inner, Marker)} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
chdir(t, inner)
|
||||
|
||||
if got := Root(""); got != inner {
|
||||
t.Errorf("Root() = %q, want the nearest marker %q", got, inner)
|
||||
}
|
||||
}
|
||||
|
||||
// A linked worktree is a SIBLING of the main checkout, so the gitignored
|
||||
// marker is never on its parent chain. The whole sync layer once died there
|
||||
// while the tracker CLI in the same directory worked.
|
||||
func TestALinkedWorktreeResolvesToTheMainCheckout(t *testing.T) {
|
||||
base := fixture(t)
|
||||
main := filepath.Join(base, "repo")
|
||||
wt := filepath.Join(base, "repo-feat")
|
||||
gitdir := filepath.Join(main, ".git", "worktrees", "feat")
|
||||
|
||||
for _, d := range []string{filepath.Join(main, Marker), gitdir, wt} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// <worktree>/.git points at the private dir; commondir points back at
|
||||
// <main>/.git, whose parent is the main working tree.
|
||||
write(t, filepath.Join(wt, ".git"), "gitdir: "+gitdir+"\n")
|
||||
write(t, filepath.Join(gitdir, "commondir"), "../..\n")
|
||||
|
||||
chdir(t, wt)
|
||||
if got := Root(""); got != main {
|
||||
t.Errorf("Root() = %q, want the main checkout %q", got, main)
|
||||
}
|
||||
}
|
||||
|
||||
// A submodule's .git is a pointer too, but it points into
|
||||
// <super>/.git/modules/…, and the tree it belongs to is already on the parent
|
||||
// chain. Following it would be a hop to nowhere.
|
||||
func TestASubmoduleIsNotAWorktree(t *testing.T) {
|
||||
base := fixture(t)
|
||||
sub := filepath.Join(base, "super", "sub")
|
||||
gitdir := filepath.Join(base, "super", ".git", "modules", "sub")
|
||||
for _, d := range []string{sub, gitdir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, filepath.Join(sub, ".git"), "gitdir: "+gitdir+"\n")
|
||||
|
||||
if got := MainWorktree(sub); got != "" {
|
||||
t.Errorf("MainWorktree() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOrdinaryCloneHasNothingToFollow(t *testing.T) {
|
||||
dir := fixture(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := GitDirOf(dir); got != "" {
|
||||
t.Errorf("GitDirOf() = %q — only a .git FILE is a pointer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProjectDirIsTheFirstAnchor(t *testing.T) {
|
||||
base := fixture(t)
|
||||
opened := filepath.Join(base, "opened")
|
||||
elsewhere := filepath.Join(base, "elsewhere")
|
||||
for _, d := range []string{filepath.Join(opened, Marker), filepath.Join(elsewhere, Marker)} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", opened)
|
||||
chdir(t, elsewhere)
|
||||
|
||||
if got := Root(""); got != opened {
|
||||
t.Errorf("Root() = %q, want the opened project %q", got, opened)
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Repo is one repository, spelled the way a tracker spells it.
|
||||
type Repo struct {
|
||||
Owner string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (r Repo) String() string {
|
||||
if r.Zero() {
|
||||
return ""
|
||||
}
|
||||
return r.Owner + "/" + r.Name
|
||||
}
|
||||
|
||||
// Zero reports whether this names no repository. Both halves are required:
|
||||
// half a name addresses nothing.
|
||||
func (r Repo) Zero() bool { return r.Owner == "" || r.Name == "" }
|
||||
|
||||
// ParseRepo reads owner/name.
|
||||
func ParseRepo(s string) (Repo, error) {
|
||||
owner, name, ok := strings.Cut(strings.TrimSpace(s), "/")
|
||||
if !ok || owner == "" || name == "" {
|
||||
return Repo{}, fmt.Errorf("repo %q is not owner/name", s)
|
||||
}
|
||||
return Repo{Owner: owner, Name: name}, nil
|
||||
}
|
||||
|
||||
// Key is a stable cross-repo handle for one issue: owner/repo#42.
|
||||
//
|
||||
// It is what the ledger is keyed by and what the `gitea:` metadata field holds,
|
||||
// so it has to survive being written to a file and read back — which is why it
|
||||
// is a repository and a number and not a bare number. A number is ambiguous the
|
||||
// moment a dependency lives in another repository, and dependencies are allowed
|
||||
// to.
|
||||
type Key struct {
|
||||
// Repo is zero when the caller named a number and nothing else, which is
|
||||
// the common case on a command line: "42" means "42 in this project's
|
||||
// repository", and which repository that is, is the client's business.
|
||||
Repo Repo
|
||||
Number int
|
||||
}
|
||||
|
||||
func (k Key) String() string {
|
||||
if k.Repo.Zero() {
|
||||
return "#" + strconv.Itoa(k.Number)
|
||||
}
|
||||
return fmt.Sprintf("%s#%d", k.Repo, k.Number)
|
||||
}
|
||||
|
||||
// In returns this key with r filled in when it names no repository of its own.
|
||||
func (k Key) In(r Repo) Key {
|
||||
if k.Repo.Zero() {
|
||||
k.Repo = r
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// The four spellings, as patterns.
|
||||
//
|
||||
// Digits and only digits after the `#`, which is the test strconv.Atoi is too
|
||||
// generous to make on its own: it accepts a sign, and `owner/repo#-3` is not a
|
||||
// handle anybody ever wrote. Anything that is not a key has to be recognizable
|
||||
// as not a key — a hand-edited metadata line and a number are told apart here
|
||||
// and nowhere else.
|
||||
var (
|
||||
keyURL = regexp.MustCompile(`^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$`)
|
||||
keyQualified = regexp.MustCompile(`^([\w.-]+/[\w.-]+)#(\d+)$`)
|
||||
keyNumber = regexp.MustCompile(`^#?(\d+)$`)
|
||||
)
|
||||
|
||||
// ParseKey reads an issue key: 42, #42, owner/repo#42, or the issue's URL.
|
||||
//
|
||||
// All four spellings because all four are what somebody has in hand — a number
|
||||
// from a receipt, a `#42` copied out of a body, a qualified key out of the
|
||||
// ledger, a URL pasted from a browser. Refusing three of them buys nothing.
|
||||
func ParseKey(s string) (Key, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if m := keyURL.FindStringSubmatch(s); m != nil {
|
||||
n, _ := strconv.Atoi(m[3])
|
||||
return Key{Repo: Repo{Owner: m[1], Name: m[2]}, Number: n}, nil
|
||||
}
|
||||
if m := keyQualified.FindStringSubmatch(s); m != nil {
|
||||
repo, err := ParseRepo(m[1])
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
return Key{Repo: repo, Number: n}, nil
|
||||
}
|
||||
if m := keyNumber.FindStringSubmatch(s); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return Key{Number: n}, nil
|
||||
}
|
||||
return Key{}, fmt.Errorf("cannot parse issue key %q — want 42, #42, owner/repo#42, or an issue URL", s)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wire_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func TestParseKey(t *testing.T) {
|
||||
acme := wire.Repo{Owner: "acme", Name: "widgets"}
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want wire.Key
|
||||
}{
|
||||
{"42", wire.Key{Number: 42}},
|
||||
{"#42", wire.Key{Number: 42}},
|
||||
{" acme/widgets#42 ", wire.Key{Repo: acme, Number: 42}},
|
||||
{"https://git.example.test/acme/widgets/issues/42", wire.Key{Repo: acme, Number: 42}},
|
||||
{"https://git.example.test/acme/widgets/issues/42/", wire.Key{Repo: acme, Number: 42}},
|
||||
} {
|
||||
got, err := wire.ParseKey(tc.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseKey(%q): %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseKey(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// What is not a key has to be refused as one. `o/r#-3` is the case a bare
|
||||
// strconv.Atoi accepts and nobody ever wrote: a key read back out of a
|
||||
// metadata line somebody hand-edited must come back as "not a key", never
|
||||
// as issue -3.
|
||||
for _, bad := range []string{"not an issue", "o/r#-3", "o/r#4x", "o/r#", "o/r", ""} {
|
||||
if got, err := wire.ParseKey(bad); err == nil {
|
||||
t.Errorf("ParseKey(%q) = %v, want a refusal", bad, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got := (wire.Key{Repo: acme, Number: 42}).String(); got != "acme/widgets#42" {
|
||||
t.Errorf("a qualified key formatted as %q", got)
|
||||
}
|
||||
if got := (wire.Key{Number: 42}).In(acme).String(); got != "acme/widgets#42" {
|
||||
t.Errorf("an unqualified key filled in as %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The protocol is shared by two layers that may not import each other, and it
|
||||
// can only be shared because it reaches for nothing itself: no domain, no
|
||||
// configuration, no path resolution, no third party. One import from any of
|
||||
// those would drag every user of this package into that layer — which is the
|
||||
// whole reason these shapes were lifted out of the transport rather than left
|
||||
// there for the bridge to reimplement.
|
||||
//
|
||||
// The dependency walk, so a helper pulled in three packages deep is caught as
|
||||
// the same violation as one written at the top of a file.
|
||||
func TestWireDependsOnNothing(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)) {
|
||||
if dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/wire" {
|
||||
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 protocol imports %s — these are shapes and identifiers, and nothing else belongs here", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: net/http and os are standard library, so "no third-party
|
||||
// imports" would not catch a transport or a file read written by hand here.
|
||||
// Name them.
|
||||
//
|
||||
// DIRECT imports, not the dependency walk — fmt reaches os on its own, and the
|
||||
// question this asks is what THIS package reaches for.
|
||||
func TestWireReachesNeitherTheNetworkNorTheDisk(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 shape reads no file and no environment",
|
||||
"os/exec": "nothing here shells out",
|
||||
"io": "nothing here is a stream",
|
||||
"time": "a timestamp crosses as the string the tracker sent",
|
||||
}
|
||||
|
||||
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("wire imports %s — %s", dep, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package wire
|
||||
|
||||
// The bodies that go up, and the shorthand that fills them.
|
||||
//
|
||||
// The omitted keys carry meaning of their own on a PATCH: a key that is absent
|
||||
// leaves the tracker's value alone, and a key that is present overwrites it. So
|
||||
// "no opinion" and "empty" must not marshal the same way, which is what every
|
||||
// pointer and every omitempty below is for.
|
||||
|
||||
// IssueRequest is the body of a create or an edit.
|
||||
//
|
||||
// Every field is a pointer because Gitea reads an absent key as "no opinion"
|
||||
// and a present one as "make it this", and the difference is not academic: an
|
||||
// empty `ref` CLEARS the branch an issue is pinned to, and an empty `labels`
|
||||
// clears its labels. A caller meaning to change only the state would do both by
|
||||
// accident with plain zero values. Set fills a field; leaving it nil leaves the
|
||||
// tracker's copy alone.
|
||||
type IssueRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Body *string `json:"body,omitempty"`
|
||||
// Labels is a pointer because `[]` is a statement — it clears every label
|
||||
// on the issue — while a caller that has not resolved label ids at all has
|
||||
// no business making it. A plain slice with omitempty cannot say both.
|
||||
Labels *[]int64 `json:"labels,omitempty"`
|
||||
Assignees *[]string `json:"assignees,omitempty"`
|
||||
// Milestone is a pointer for the same reason, and because 0 is Gitea's
|
||||
// "detach from its milestone" — a value somebody may well mean.
|
||||
Milestone *int64 `json:"milestone,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
Ref *string `json:"ref,omitempty"`
|
||||
}
|
||||
|
||||
// LabelRequest is the body of a label create or edit — everything a repository
|
||||
// needs to make one label.
|
||||
//
|
||||
// Value fields, not pointers, and every one of them is sent: Gitea 1.26 patches
|
||||
// only what it is given, but an older server reads an absent field as empty and
|
||||
// blanks it. A label edit is rare enough that sending the unchanged name and
|
||||
// description along costs nothing and removes a way to lose them.
|
||||
//
|
||||
// It goes up as a request body of its own because `tea labels create` could not
|
||||
// set `exclusive` — the flag that makes `type/*` behave like a single choice —
|
||||
// which is the whole reason label creation went through the API rather than a
|
||||
// CLI wrapper.
|
||||
//
|
||||
// What a label MEANS — which namespaces are exclusive, what colour a severity
|
||||
// is — is not decided here. This is the shape; the taxonomy is the domain's and
|
||||
// the palette is the bridge's.
|
||||
type LabelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
Exclusive bool `json:"exclusive"`
|
||||
}
|
||||
|
||||
// Set is a pointer to v, for filling the optional fields of a request. Gitea
|
||||
// reads an absent key as "no opinion" and a present one as "make it this", so
|
||||
// those fields are pointers and this is the shorthand that fills them.
|
||||
func Set[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package wire is the protocol: the JSON shapes a Gitea instance sends and
|
||||
// takes, the identifiers that address them, and nothing else.
|
||||
//
|
||||
// It is a package because two layers need the same vocabulary and neither may
|
||||
// import the other. internal/gitea is the transport — HTTP verbs, pagination,
|
||||
// status codes, credentials — and internal/mapping is the bridge — md <-> JSON,
|
||||
// pure functions, no network. Both have to name a Gitea issue, and when each
|
||||
// named it with a struct of its own, every command written on top of the two
|
||||
// would have had to copy a payload field by field from one spelling into the
|
||||
// other. Two copies of a shape also drift: the first field only one of them
|
||||
// learns is a field the other silently drops.
|
||||
//
|
||||
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
|
||||
// filesystem, no configuration, and above all not internal/issue. That is what
|
||||
// lets the transport and the bridge share it without either one landing inside
|
||||
// the other's layer, and layering_test.go fails the moment it stops being true.
|
||||
//
|
||||
// Structs and not map[string]any, because the two representations disagreeing
|
||||
// is the failure this vocabulary exists to make debuggable: a typo in a key is
|
||||
// a compile error here and a silently dropped field there. Anything Gitea sends
|
||||
// that is not named below is not read by anybody — decoding is lossy on
|
||||
// purpose, since the tracker is not the record for anything the domain owns.
|
||||
package wire
|
||||
|
||||
// User is whoever wrote or was assigned something.
|
||||
//
|
||||
// Only the login crosses this boundary — it is the one field of a Gitea user
|
||||
// that means anything to a command, it is what `assignees:` holds, and a
|
||||
// display name is not an identity anything can be pushed against. A transport
|
||||
// that carries the rest invites somebody to use it.
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
// Label as the tracker holds it.
|
||||
//
|
||||
// Color is hex. Gitea returns it without the leading `#` (`ee0701`) and accepts
|
||||
// it either way; both spellings are the same color, so a comparison has to
|
||||
// strip before it compares.
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
Exclusive bool `json:"exclusive"`
|
||||
}
|
||||
|
||||
// Milestone as the tracker holds it. The domain carries its title; the id
|
||||
// exists only long enough to be sent back.
|
||||
type Milestone struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// RepoRef is the repository an issue payload says it belongs to. Present on a
|
||||
// dependency listing, where the answer may well be another repository.
|
||||
type RepoRef struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
// PullRequest is non-nil on a row that is a pull request rather than an issue.
|
||||
// Gitea's issue endpoints return both, and `type=issues` is a filter the server
|
||||
// has been known to ignore — which is why every listing re-checks it.
|
||||
type PullRequest struct {
|
||||
Merged bool `json:"merged"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Issue is a tracker row: a Gitea issue as the API reports it.
|
||||
//
|
||||
// Timestamps stay strings. They are written into an issue's metadata verbatim
|
||||
// and compared as opaque values; parsing them here would mean formatting them
|
||||
// back, and a round trip through a time package is a chance to hand the store a
|
||||
// different string than the tracker sent.
|
||||
type Issue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
// Ref is the branch the issue is pinned to.
|
||||
Ref string `json:"ref"`
|
||||
// HTMLURL and UpdatedAt are the tracker's own bookkeeping and land in the
|
||||
// domain's Extra untouched.
|
||||
HTMLURL string `json:"html_url"`
|
||||
// Comments is a count, not a thread: the thread is fetched separately and
|
||||
// parked beside the issue as a sidecar.
|
||||
Comments int `json:"comments"`
|
||||
Labels []Label `json:"labels"`
|
||||
Assignees []User `json:"assignees"`
|
||||
Milestone *Milestone `json:"milestone"`
|
||||
Repository *RepoRef `json:"repository"`
|
||||
PullRequest *PullRequest `json:"pull_request"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IsPullRequest reports whether this row is a pull request.
|
||||
func (i *Issue) IsPullRequest() bool { return i.PullRequest != nil }
|
||||
|
||||
// LabelNames are the label names, in the order the tracker listed them.
|
||||
func (i *Issue) LabelNames() []string {
|
||||
out := make([]string, 0, len(i.Labels))
|
||||
for _, l := range i.Labels {
|
||||
out = append(out, l.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AssigneeLogins are the assignees, as logins.
|
||||
func (i *Issue) AssigneeLogins() []string {
|
||||
out := make([]string, 0, len(i.Assignees))
|
||||
for _, a := range i.Assignees {
|
||||
out = append(out, a.Login)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MilestoneTitle is the milestone's title, or "" when there is none.
|
||||
func (i *Issue) MilestoneTitle() string {
|
||||
if i.Milestone == nil {
|
||||
return ""
|
||||
}
|
||||
return i.Milestone.Title
|
||||
}
|
||||
|
||||
// KeyIn is this issue's cross-repo handle. The payload's own repository wins
|
||||
// when it carries one — a dependency listing answers with issues from other
|
||||
// repositories — and fallback is the repository that was asked.
|
||||
func (i *Issue) KeyIn(fallback Repo) Key {
|
||||
repo := fallback
|
||||
if i.Repository != nil {
|
||||
if r, err := ParseRepo(i.Repository.FullName); err == nil {
|
||||
repo = r
|
||||
}
|
||||
}
|
||||
return Key{Repo: repo, Number: i.Number}
|
||||
}
|
||||
|
||||
// Comment is one entry in an issue's thread.
|
||||
//
|
||||
// Read only, in practice: a thread is flattened to markdown for a reader and
|
||||
// nothing writes that markdown back, which is why the rendering may be as lossy
|
||||
// as a reader needs.
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
User User `json:"user"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
Reference in New Issue
Block a user