Files
marketplace/cli/internal/cmd/labels.go
T
naudachu 9480e48312 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>
2026-08-11 19:05:39 +05:00

286 lines
9.7 KiB
Go

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, ", ")
}