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