9480e48312
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>
165 lines
4.1 KiB
Go
165 lines
4.1 KiB
Go
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)
|
|
}
|