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