package cmd import ( "flag" "fmt" "os" "regexp" "strings" sdk "code.gitea.io/sdk/gitea" "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 sdk.CreateLabelOption got *sdk.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 []sdk.CreateLabelOption, existing []*sdk.Label) ([]labelRow, []labelLookalike) { byName := make(map[string]*sdk.Label, len(existing)) for _, l := range existing { byName[l.Name] = l } 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 _, l := range existing { 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 sdk.CreateLabelOption, got *sdk.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, ", ") }