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>
286 lines
9.7 KiB
Go
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, ", ")
|
|
}
|