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>
127 lines
4.3 KiB
Go
127 lines
4.3 KiB
Go
package mapping
|
|
|
|
import (
|
|
"strings"
|
|
|
|
sdk "code.gitea.io/sdk/gitea"
|
|
|
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
|
)
|
|
|
|
// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
|
// an issue IS, which is exactly why the table lives here and not in the domain
|
|
// — internal/issue/taxonomy.go says as much where the labels themselves are.
|
|
//
|
|
// The keys are the canonical set and nothing else. TestEveryCanonicalLabelHasA
|
|
// Color walks issue.CanonicalLabels() and fails on a gap, so a type or a
|
|
// severity added over there cannot quietly arrive here as grey.
|
|
var labelColors = map[string]string{
|
|
"type/bug": "#ee0701",
|
|
"type/task": "#0e8a16",
|
|
"type/refactor": "#1d76db",
|
|
"type/test": "#fbca04",
|
|
"type/feature": "#5319e7",
|
|
"type/draft": "#cccccc",
|
|
"severity/low": "#c2e0c6",
|
|
"severity/medium": "#fbca04",
|
|
"severity/high": "#eb6420",
|
|
"severity/showstopper": "#ee0701",
|
|
"severity/critical": "#b60205",
|
|
}
|
|
|
|
// DefaultColor paints everything outside the canonical set. `tech/*` and
|
|
// `comp/*` are project-specific and have no preset, so guessing a color for one
|
|
// would be inventing a meaning it does not have.
|
|
const DefaultColor = "#ededed"
|
|
|
|
// LabelColor is the hex code a label is painted with in the tracker.
|
|
func LabelColor(name string) string {
|
|
if c, ok := labelColors[name]; ok {
|
|
return c
|
|
}
|
|
return DefaultColor
|
|
}
|
|
|
|
// LabelSpecs is the request body for each name, in the order given.
|
|
//
|
|
// The SDK's own CreateLabelOption and not a shape of this package's own: it is
|
|
// field for field what a label create takes, and a second spelling of it would
|
|
// mean the bootstrap command copying four fields across on its way to the
|
|
// transport. It is what an EDIT is built from too — see Client.EditLabel — so
|
|
// one value says what a label should be, whether or not it exists yet.
|
|
// Exclusivity and meaning come from the domain taxonomy; only the color is
|
|
// decided here.
|
|
//
|
|
// A slice and not a map: the order is the taxonomy's, and a bootstrap prints
|
|
// its plan in that order — a map would shuffle the plan on every run and make
|
|
// two identical runs look like different ones.
|
|
func LabelSpecs(names []string) []sdk.CreateLabelOption {
|
|
ns := exclusiveNamespaces()
|
|
out := make([]sdk.CreateLabelOption, 0, len(names))
|
|
for _, name := range names {
|
|
out = append(out, sdk.CreateLabelOption{
|
|
Name: name,
|
|
Color: LabelColor(name),
|
|
Description: typeMeaning(name),
|
|
Exclusive: hasAnyPrefix(name, ns),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CanonicalLabelSpecs is the set a repository needs before a push can attach
|
|
// anything.
|
|
//
|
|
// Derived from the domain's own list rather than restated: add a type over in
|
|
// the taxonomy and the next bootstrap creates it, with no line changing here
|
|
// except the color it is painted with.
|
|
func CanonicalLabelSpecs() []sdk.CreateLabelOption { return LabelSpecs(issue.CanonicalLabels()) }
|
|
|
|
// exclusiveNamespaces are the namespaces at most one label may come from, read
|
|
// off the canonical set rather than listed again — the domain publishes exactly
|
|
// the exclusive namespaces there, in full, and that is what makes the set
|
|
// canonical.
|
|
//
|
|
// A prefix test and not a membership test, on purpose: a project's own
|
|
// `type/spike` is still exclusive. Being one of a set of alternatives is a
|
|
// property of the namespace, not of the members the taxonomy happens to know.
|
|
func exclusiveNamespaces() []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, name := range issue.CanonicalLabels() {
|
|
ns, _, ok := strings.Cut(name, "/")
|
|
if !ok || seen[ns] {
|
|
continue
|
|
}
|
|
seen[ns] = true
|
|
out = append(out, ns+"/")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// typeMeaning is the description a `type/*` label carries into the tracker, so
|
|
// the meaning a reader needs is on the chip rather than in this repository.
|
|
// Nothing else gets one: a severity explains itself, and a project's own
|
|
// namespaces are not ours to describe.
|
|
func typeMeaning(name string) string {
|
|
tail, ok := strings.CutPrefix(name, "type/")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
for _, t := range issue.Types {
|
|
if t.Name == tail {
|
|
return t.Meaning
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hasAnyPrefix(s string, prefixes []string) bool {
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(s, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|