1239fdee70
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
287 lines
9.7 KiB
Go
287 lines
9.7 KiB
Go
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, ", ")
|
|
}
|