From 6e54c7f6c6411ad3e18be5829874349fdf3cad7a Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:24:59 +0100 Subject: [PATCH] feat(labels): estate label tooling + auto-triage for new issues Ships the canonical label set and the classifier that labels newly-filed issues. Additive only: it never removes a label, never overrides a human's classification, stays silent when unsure, and never fails an issue. Also adds this repo's two new workflows to .github/workflows/actions.lock as '[]'. That lock is keyed by workflow path and refuses any workflow it does not list -- a startup_failure, which produces no check run and is therefore silent. `gh actions-lock` cannot add these: it records action versions, and both workflows deliberately use no actions. Co-Authored-By: Claude Opus 5 --- .github/label-classifier.json | 739 +++++++++++++++++++++++++++++ .github/labels.json | 260 ++++++++++ .github/scripts/classify-issue.jq | 164 +++++++ .github/workflows/label-triage.yml | 116 +++++ .github/workflows/labels.yml | 105 ++++ 5 files changed, 1384 insertions(+) create mode 100644 .github/label-classifier.json create mode 100644 .github/labels.json create mode 100644 .github/scripts/classify-issue.jq create mode 100644 .github/workflows/label-triage.yml create mode 100644 .github/workflows/labels.yml diff --git a/.github/label-classifier.json b/.github/label-classifier.json new file mode 100644 index 0000000..d349eaa --- /dev/null +++ b/.github/label-classifier.json @@ -0,0 +1,739 @@ +{ + "_generated_from": ".github/label-classifier.yml + .github/labels.yml in hyperpolymath/.git-private-farm", + "_do_not_edit": "regenerate with scripts/gen-classifier-json.py", + "version": 1, + "prefix_split_on": "/", + "title_prefix": { + "docs": { + "type": "documentation" + }, + "ci": { + "type": "chore", + "areas": [ + "cicd" + ] + }, + "governance": { + "type": "chore", + "areas": [ + "governance" + ] + }, + "roadmap": { + "type": "enhancement", + "meta": "meta:roadmap" + }, + "chore": { + "type": "chore" + }, + "build": { + "type": "chore", + "areas": [ + "cicd" + ] + }, + "security": { + "type": "chore", + "areas": [ + "security" + ] + }, + "proof": { + "type": "chore", + "areas": [ + "proofs" + ] + }, + "proofs": { + "type": "chore", + "areas": [ + "proofs" + ] + }, + "proof-debt": { + "type": "tech-debt", + "areas": [ + "proofs" + ] + }, + "epic": { + "type": "enhancement", + "meta": "meta:umbrella" + }, + "umbrella": { + "type": "enhancement", + "meta": "meta:umbrella" + }, + "tracking": { + "type": "chore", + "meta": "meta:umbrella" + }, + "campaign": { + "type": "enhancement", + "meta": "meta:campaign" + }, + "hygiene": { + "type": "tech-debt" + }, + "audit": { + "type": "research" + }, + "estate": { + "type": "chore", + "scope": "scope:estate" + }, + "automation": { + "type": "enhancement", + "areas": [ + "automation" + ] + }, + "research": { + "type": "research" + }, + "refactor": { + "type": "refactor" + }, + "test": { + "type": "testing" + }, + "tests": { + "type": "testing" + }, + "feat": { + "type": "enhancement" + }, + "fix": { + "type": "bug" + }, + "bug": { + "type": "bug" + }, + "perf": { + "type": "enhancement", + "areas": [ + "performance" + ] + }, + "codegen": { + "type": "enhancement", + "areas": [ + "architecture" + ] + }, + "packaging": { + "type": "chore", + "areas": [ + "packaging" + ] + }, + "policy": { + "type": "chore", + "areas": [ + "governance" + ] + }, + "ops": { + "type": "chore", + "areas": [ + "automation" + ] + }, + "standard": { + "type": "chore", + "areas": [ + "governance" + ] + }, + "migration": { + "type": "refactor", + "areas": [ + "migration" + ] + }, + "drift": { + "type": "tech-debt" + }, + "corrective": { + "type": "bug" + }, + "adaptive": { + "type": "enhancement" + }, + "perfective": { + "type": "enhancement" + }, + "preventive": { + "type": "tech-debt" + }, + "machine-readable": { + "type": "tech-debt" + }, + "parser": { + "type": "bug" + }, + "lang": { + "type": "bug" + }, + "clippy": { + "type": "tech-debt" + }, + "release": { + "type": "chore" + }, + "upstream": { + "type": "chore" + }, + "hardening": { + "type": "chore", + "areas": [ + "security" + ] + }, + "deps": { + "type": "chore" + }, + "rustsec": { + "type": "chore", + "areas": [ + "security" + ] + }, + "track": { + "type": "chore", + "meta": "meta:umbrella" + }, + "tracker": { + "type": "chore", + "meta": "meta:umbrella" + }, + "wiki": { + "type": "documentation" + }, + "reclassify": { + "type": "refactor" + }, + "backlog": { + "type": "chore" + }, + "core": { + "type": "enhancement", + "areas": [ + "design" + ] + }, + "evidence": { + "type": "enhancement", + "areas": [ + "design" + ] + }, + "manifest": { + "type": "enhancement", + "areas": [ + "design" + ] + }, + "backends": { + "type": "enhancement", + "areas": [ + "design" + ] + } + }, + "bracket_tag": { + "campaign": { + "meta": "meta:campaign" + }, + "umbrella": { + "meta": "meta:umbrella" + }, + "gov": { + "areas": [ + "governance" + ] + }, + "proofs/a": { + "areas": [ + "proofs" + ] + }, + "proofs/b": { + "areas": [ + "proofs" + ] + }, + "proofs/c": { + "areas": [ + "proofs" + ] + }, + "estate": { + "scope": "scope:estate" + }, + "repo": { + "scope": "scope:repo" + }, + "feature": { + "type": "enhancement" + }, + "integration": { + "areas": [ + "conformance" + ] + }, + "reference": { + "type": "documentation" + }, + "register": { + "type": "documentation" + }, + "p0": { + "priority": "priority:p0" + }, + "p1": { + "priority": "priority:p1" + }, + "p2": { + "priority": "priority:p2" + }, + "et-l2": { + "areas": [ + "conformance" + ] + }, + "et-l4": { + "areas": [ + "conformance" + ] + } + }, + "keyword_area": { + "proofs": [ + "agda", + "coq", + "rocq", + "idris", + "lean", + "isabelle", + "hol", + "mizar", + "why3", + "tla", + "alloy", + "dafny", + "acl2", + "pvs", + "metamath", + "z3", + "smt", + "prover", + "provers", + "theorem", + "theorems", + "axiom", + "axioms", + "postulate", + "postulates", + "believe_me", + "sorry", + "proof obligation", + "proof obligations", + "proof hole", + "proof holes", + "proof suite", + "proof-pipeline", + "proof debt", + "proof-debt", + "metatheory", + "mechanize", + "qed" + ], + "cicd": [ + "workflow", + "github action", + "actions.lock", + "lockfile", + "runner", + "startup_failure", + "dependabot", + "check run", + "required context", + "scorecard", + "codeql", + "ci/cd" + ], + "licensing": [ + "spdx", + "licence", + "license", + "reuse", + "copyright", + "attribution", + "agpl", + "mpl" + ], + "security": [ + "gitleaks", + "secret", + "vulnerabilit", + "advisory", + "supply chain", + "cve" + ], + "bindings": [ + "abi", + "ffi", + "wasm", + "jni", + "c api", + "interop", + "extern \"c\"", + "nif", + "snif" + ], + "packaging": [ + "guix", + "nix", + "container", + "containerfile", + "docker", + "flatpak", + "oci image" + ], + "scaffolding": [ + "rsr", + "scaffold", + "template", + "repo-init", + "instantiat", + "placeholder" + ], + "governance": [ + "ruleset", + "policy", + "compliance", + "governance", + "branch protection", + "codeowners", + "code of conduct" + ], + "migration": [ + "rescript", + "to-affinescript", + "\u2192 affinescript", + "port", + "deno", + "bun" + ], + "automation": [ + "bot", + "gitbot", + "hypatia", + "sustainabot", + "oikosbot", + "fan-out", + "fanout", + "dispatch", + "self-heal" + ], + "performance": [ + "latency", + "throughput", + "binary size", + "memory", + "hot path", + "regression" + ] + }, + "keyword_type": { + "tech-debt": [ + "debt", + "drift", + "hygiene", + "stale", + "cleanup", + "follow-up", + "clean up", + "left over", + "leftover", + "anti-pattern", + "inconsistency", + "inconsistent", + "placeholder", + "placeholders", + "tbd", + "todo", + "todos", + "unfilled" + ], + "documentation": [ + "document", + "docs", + "readme", + "adoc", + "prose", + "docs/", + "changelog", + "explainme", + "quickstart", + "wiki", + "docstring", + "doc tree" + ], + "testing": [ + "test", + "tests", + "fuzz", + "bench", + "coverage", + "crash-consistency", + "linearizability", + "equivalence", + "property-correspondence", + "property-based", + "test suite", + "proptest" + ], + "bug": [ + "broken", + "fails", + "failing", + "crash", + "oom", + "regression", + "incorrect", + "does not", + "panic", + "panics", + "unreachable", + "mangled", + "never run", + "never ran", + "never succeeded", + "never fires", + "cannot fail", + "deadlock", + "hangs" + ], + "refactor": [ + "refactor", + "restructure", + "consolidate", + "consolidation", + "reconcile", + "reconciliation", + "unify", + "dedupe", + "re-point", + "repoint", + "extract", + "retire", + "retire duplicate", + "deduplicate", + "reclassify", + "migrate" + ], + "research": [ + "investigat", + "explore", + "spike", + "work out", + "triage", + "assess", + "survey", + "gap analysis", + "self-audit", + "inventory", + "weakness list", + "theory", + "synthesis", + "prioritised weakness", + "feasibility" + ], + "decision": [ + "ruling", + "decide", + "decision", + "adjudicat", + "which of" + ], + "enhancement": [ + "add", + "implement", + "support", + "introduce", + "enable", + "expand", + "expansion", + "extend", + "wire", + "complete", + "build", + "create", + "port" + ] + }, + "meta_signal": { + "meta:umbrella": [ + "umbrella", + "epic", + "master issue", + "parent issue", + "sub-issues", + "child issues" + ], + "meta:campaign": [ + "campaign" + ], + "meta:roadmap": [ + "roadmap", + "capability-expansion", + "future work" + ], + "meta:recurring": [ + "recurring", + "recurrence", + "standing", + "every run", + "each week" + ] + }, + "status_signal": { + "status:blocked": [ + "blocked on", + "blocked:", + "(blocked", + "is blocked", + "gated on", + "waiting on upstream", + "needs upstream" + ], + "status:needs-owner": [ + "unassigned", + "needs an owner", + "no owner" + ], + "status:needs-ruling": [ + "needs a ruling", + "awaiting ruling", + "owner decision needed" + ] + }, + "scope_signal": { + "scope:estate": [ + "estate-wide", + "estate wide", + "across the estate", + "all repos", + "fleet-wide" + ] + }, + "tier_of": { + "bug": "type", + "enhancement": "type", + "documentation": "type", + "refactor": "type", + "tech-debt": "type", + "testing": "type", + "chore": "type", + "research": "type", + "decision": "type", + "question": "type", + "cicd": "area", + "security": "area", + "proofs": "area", + "governance": "area", + "design": "area", + "architecture": "area", + "performance": "area", + "bindings": "area", + "migration": "area", + "packaging": "area", + "licensing": "area", + "automation": "area", + "scaffolding": "area", + "conformance": "area", + "priority:p0": "priority", + "priority:p1": "priority", + "priority:p2": "priority", + "priority:p3": "priority", + "status:blocked": "status", + "status:ready": "status", + "status:needs-owner": "status", + "status:needs-ruling": "status", + "status:do-not-automate": "status", + "meta:umbrella": "meta", + "meta:campaign": "meta", + "meta:roadmap": "meta", + "meta:recurring": "meta", + "scope:estate": "scope", + "scope:repo": "scope" + }, + "tier_max": { + "type": 1, + "area": null, + "priority": 1, + "status": 1, + "meta": 1, + "scope": 1 + }, + "types": [ + "bug", + "enhancement", + "documentation", + "refactor", + "tech-debt", + "testing", + "chore", + "research", + "decision", + "question" + ], + "frozen": [ + "dependencies", + "duplicate", + "elixir", + "gitar-approved", + "github_actions", + "good first issue", + "help wanted", + "invalid", + "javascript", + "never-stale", + "nix", + "pinned", + "python", + "rust", + "security", + "stale", + "wontfix" + ], + "precedence": { + "meta:campaign": 0, + "meta:umbrella": 1, + "meta:recurring": 2, + "meta:roadmap": 3, + "priority:p0": 0, + "priority:p1": 1, + "priority:p2": 2, + "priority:p3": 3, + "status:blocked": 0, + "status:needs-ruling": 1, + "status:needs-owner": 2, + "status:do-not-automate": 3, + "status:ready": 4, + "scope:estate": 0, + "scope:repo": 1, + "bug": 0, + "decision": 1, + "tech-debt": 2, + "testing": 3, + "documentation": 4, + "refactor": 5, + "research": 6, + "enhancement": 7, + "chore": 8, + "question": 9 + } +} diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..78786d4 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,260 @@ +{ + "_generated_from": ".github/labels.yml in hyperpolymath/.git-private-farm", + "_do_not_edit": "regenerate with scripts/gen-labels-json.py", + "version": 1, + "labels": [ + { + "name": "bug", + "color": "d73a4a", + "description": "Something is broken or behaves incorrectly", + "tier": "type" + }, + { + "name": "enhancement", + "color": "a2eeef", + "description": "New capability or improvement to existing behaviour", + "tier": "type" + }, + { + "name": "documentation", + "color": "0075ca", + "description": "Docs, prose, diagrams, READMEs, ADRs", + "tier": "type" + }, + { + "name": "refactor", + "color": "c5def5", + "description": "Restructuring that preserves observable behaviour", + "tier": "type" + }, + { + "name": "tech-debt", + "color": "fbca04", + "description": "Known shortcut, drift, or hygiene owed - includes cleanup", + "tier": "type" + }, + { + "name": "testing", + "color": "bfd4f2", + "description": "Tests, benchmarks, fuzzing, property checks, coverage", + "tier": "type" + }, + { + "name": "chore", + "color": "ededed", + "description": "Routine maintenance with no behaviour change", + "tier": "type" + }, + { + "name": "research", + "color": "d4c5f9", + "description": "Open investigation; the outcome is knowledge, not code", + "tier": "type" + }, + { + "name": "decision", + "color": "8b5cf6", + "description": "A ruling is required before work can proceed", + "tier": "type" + }, + { + "name": "question", + "color": "d876e3", + "description": "Further information is requested", + "tier": "type" + }, + { + "name": "cicd", + "color": "006b75", + "description": "CI/CD: workflows, actions, lockfiles, pins, runners, release gates", + "tier": "area" + }, + { + "name": "security", + "color": "006b75", + "description": "Security posture, secrets, scanning, advisories, supply chain", + "tier": "area" + }, + { + "name": "proofs", + "color": "006b75", + "description": "Formal verification: Agda, Coq, Idris, Lean, Z3/SMT, axiom debt", + "tier": "area" + }, + { + "name": "governance", + "color": "006b75", + "description": "Policy, rulesets, standards, compliance, and their enforcement", + "tier": "area" + }, + { + "name": "design", + "color": "006b75", + "description": "Design of an interface, protocol, grammar, or type theory", + "tier": "area" + }, + { + "name": "architecture", + "color": "006b75", + "description": "Structural/system-level shape and runtime behaviour", + "tier": "area" + }, + { + "name": "performance", + "color": "006b75", + "description": "Throughput, latency, memory, binary size", + "tier": "area" + }, + { + "name": "bindings", + "color": "006b75", + "description": "ABI, FFI, WASM, and cross-language interop surfaces", + "tier": "area" + }, + { + "name": "migration", + "color": "006b75", + "description": "Porting between languages or toolchains (e.g. -> AffineScript)", + "tier": "area" + }, + { + "name": "packaging", + "color": "006b75", + "description": "Guix, Nix, containers, distribution artefacts", + "tier": "area" + }, + { + "name": "licensing", + "color": "006b75", + "description": "Licences, SPDX headers, REUSE compliance, attribution", + "tier": "area" + }, + { + "name": "automation", + "color": "006b75", + "description": "Bots, schedulers, dispatch, self-healing, fan-out", + "tier": "area" + }, + { + "name": "scaffolding", + "color": "006b75", + "description": "RSR templates, repo init, instantiation, project skeletons", + "tier": "area" + }, + { + "name": "conformance", + "color": "006b75", + "description": "Conformance to an external or internal specification", + "tier": "area" + }, + { + "name": "priority:p0", + "color": "b60205", + "description": "Critical - drop other work", + "tier": "priority" + }, + { + "name": "priority:p1", + "color": "d93f0b", + "description": "High - schedule next", + "tier": "priority" + }, + { + "name": "priority:p2", + "color": "e99695", + "description": "Normal - queue it", + "tier": "priority" + }, + { + "name": "priority:p3", + "color": "f9d0c4", + "description": "Low - nice to have", + "tier": "priority" + }, + { + "name": "status:blocked", + "color": "fbca04", + "description": "Cannot proceed until a dependency clears", + "tier": "status" + }, + { + "name": "status:ready", + "color": "fbca04", + "description": "Fully specified and ready to be picked up", + "tier": "status" + }, + { + "name": "status:needs-owner", + "color": "fbca04", + "description": "Unassigned and needs someone to take it", + "tier": "status" + }, + { + "name": "status:needs-ruling", + "color": "fbca04", + "description": "Awaiting an owner decision", + "tier": "status" + }, + { + "name": "status:do-not-automate", + "color": "fbca04", + "description": "Bots and sweeps must not touch this issue", + "tier": "status" + }, + { + "name": "meta:umbrella", + "color": "5319e7", + "description": "Parent issue aggregating child issues", + "tier": "meta" + }, + { + "name": "meta:campaign", + "color": "5319e7", + "description": "Coordinated multi-repo push with a defined end state", + "tier": "meta" + }, + { + "name": "meta:roadmap", + "color": "5319e7", + "description": "Forward planning; not yet actionable work", + "tier": "meta" + }, + { + "name": "meta:recurring", + "color": "5319e7", + "description": "Recurs on a schedule or by trigger; never finally closed", + "tier": "meta" + }, + { + "name": "scope:estate", + "color": "bfdadc", + "description": "Affects many or all repos across the estate", + "tier": "scope" + }, + { + "name": "scope:repo", + "color": "bfdadc", + "description": "Confined to this repository", + "tier": "scope" + } + ], + "frozen": [ + "dependencies", + "duplicate", + "elixir", + "gitar-approved", + "github_actions", + "good first issue", + "help wanted", + "invalid", + "javascript", + "never-stale", + "nix", + "pinned", + "python", + "rust", + "security", + "stale", + "wontfix" + ] +} diff --git a/.github/scripts/classify-issue.jq b/.github/scripts/classify-issue.jq new file mode 100644 index 0000000..6467c74 --- /dev/null +++ b/.github/scripts/classify-issue.jq @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Classify one issue title against the estate label taxonomy. +# +# jq -r --arg title "docs: fix the README" \ +# --argjson have '[]' \ +# -f .github/scripts/classify-issue.jq .github/label-classifier.json +# +# Prints one label per line, or NOTHING when it cannot place the issue +# confidently. Nothing printed means "leave it for a human" -- a correct +# outcome, not a failure. +# +# WHY jq AND NOT PYTHON +# +# Python is fully banned estate-wide: the `governance / Language / package +# anti-pattern policy` gate runs `git ls-files '*.py'` and fails the PR +# ("Python is fully banned -- use AffineScript/Rust/SPARK/Julia"). This file +# is dispatched into every repo in the estate, so shipping it as .py would +# mean shipping an exemption into every repo too -- normalising the policy +# away by sweep. jq is preinstalled on every GitHub runner, is not on the +# banned list, needs no action (so no actions.lock entry can drift), and the +# rules are already JSON. +# +# The canonical implementation remains scripts/label-classify.py in the hub, +# which never runs in CI. tests/test-classifier-parity.py asserts this file +# agrees with it on every title in the corpus. +# +# `$have` lists labels the issue already carries. Anything already present is +# never re-suggested, and the classifier stays out of any max-1 tier the issue +# already has a label in, so a human's classification is never overridden. + +# Escape every non-alphanumeric so a keyword is matched literally. Escaping +# punctuation that needs no escape is harmless in Oniguruma. +def reesc: gsub("(?[^A-Za-z0-9 _])"; "\\\(.c)"); + +def norm: (. // "") | ascii_downcase + | sub("^[[:space:]]+"; "") | sub("[[:space:]]+$"; ""); + +# Asymmetric boundary: STRICT on the left, inflection-tolerant on the right. +# +# Measured over the issue corpus, the two error directions are not symmetric: +# * every false positive is a LEFT-side prefix -- `lean` in "clean up", +# `abi` in "capability", `mpl` in "Implement", `ffi` in "AffineScript", +# `smt` in "wasmtime". The left boundary must stay strict. +# * every real miss is a RIGHT-side inflection -- `test` vs "tests", +# `theorem` vs "theorems", `todo` vs "TODOs", `scaffold` vs "scaffolding". +# +# The right side therefore admits a CLOSED set of inflections. Closed, not open +# (`.*`), because an open right side re-admits the prefix false positives. +# +# `ion`/`ation` are excluded from the base set: they mint unrelated words +# (`port` + `ion` = "portion", and `port` is a live keyword). They are enabled +# only for shapes that are unambiguously truncated stems -- `-at` +# (instantiat, investigat, adjudicat) and `-ment` (document, implement). +def kwrx($kw): + ( "s|es|ed|d|ing|er|ers|y|ies" + + (if ($kw | endswith("at")) then "|ion|ions|e" + elif ($kw | endswith("ment")) then "|ation|ations" + else "" end) + ) as $suf + # Boundaries are conditional: a keyword not starting alphanumeric has no left + # boundary to enforce, and one not ending alphanumeric takes no suffix. + | (if ($kw | test("^[A-Za-z0-9]")) then "(?[^\\]]{1,25})\\]")) // null) as $m + | if $m == null then {rule: null, rest: $t} + else (($m.tag | norm | split("#")[0]) | norm) as $tag + | { rule: ($R.bracket_tag[$tag] // null), + rest: ($t | sub("^[[:space:]]*\\[[^\\]]{1,25}\\]"; "")) } + end; + +# Leading `word:` / `word(scope):` conventional-commit prefix. +def prefixrule($R; $t): + (($t | capture("^[[:space:]]*(?[A-Za-z][A-Za-z0-9_./-]{1,24})(?:[[:space:]]*\\([^)]*\\))?[[:space:]]*:")) // null) as $m + | if $m == null then null + else ($m.w | norm) as $k + # Compound prefixes such as "adaptive/must:" carry their meaning in the + # ISO 14764 category only; the modality does not label. + | (if ($R.prefix_split_on // "") != "" and ($k | contains($R.prefix_split_on)) + then ($k | split($R.prefix_split_on) | .[0]) else $k end) as $key + | ($R.title_prefix[$key] // null) + end; + +def signals($R; $tl; $sec): + [ ($R[$sec] // {}) | to_entries[] + | select(.value | any(. as $k | kwhit($k; $tl))) + | .key ]; + +# The HIGHEST-PRECEDENCE matching type, not merely the first in key order. +def kwtype($R; $tl): + [ $R.keyword_type | to_entries[] + | select(.value | any(. as $k | kwhit($k; $tl))) + | .key ] + | if length == 0 then null + else min_by([($R.precedence[.] // 99), .]) end; + +# Drop violations of each tier's `max`, keeping the highest-precedence member. +def enforce($R; $labels): + ($labels | unique) + | group_by($R.tier_of[.] // "?") + | map( ($R.tier_of[.[0]] // "?") as $tier + | ($R.tier_max[$tier] // null) as $mx + | if $mx == null or (length <= $mx) then . + else (sort_by([($R.precedence[.] // 99), .]))[0:$mx] end ) + | flatten; + +def classify($R; $title; $have0): + ($title // "") as $t0 + | ($t0 | norm) as $tl + | ($have0 | map(select(. != null and . != "")) + | unique) as $have + | ($R.tier_of | keys) as $canon + | $R.types as $types + | bracket($R; $t0) as $b + | (if $b.rule != null then ($b.rule | rulelabels) else [] end) as $l1 + | prefixrule($R; $b.rest) as $pr + | (if $pr != null then ($pr | rulelabels) else [] end) as $l2 + | (($b.rule != null) or ($pr != null)) as $matched0 + # 3. keyword areas are additive and never contribute a type + | ($l1 + $l2 + signals($R; $tl; "keyword_area")) as $acc + # 4. a type only if neither the rules nor the issue already supplied one + | (if (($acc + $have) | any(. as $x | $types | index($x))) + then null else kwtype($R; $tl) end) as $ty + | ($acc + (if $ty != null then [$ty] else [] end)) as $acc + | ($matched0 or ($ty != null)) as $matched + | ( $acc + + signals($R; $tl; "status_signal") + + signals($R; $tl; "meta_signal") + + signals($R; $tl; "scope_signal") ) as $acc + # NOTE: `frozen` is deliberately NOT subtracted. Frozen means "never rename or + # delete this label" -- `security` is frozen because triage.yml pins it in + # exempt-issue-labels. APPLYING it to an issue is correct; only the + # definition is protected. + | ($acc | map(select(. as $x | $canon | index($x))) | unique) as $acc + | enforce($R; $acc + ($have | map(select(. as $x | $canon | index($x))))) as $acc + | ($acc - $have) as $out + # Stay out of any max-1 tier the issue ALREADY has a label in -- a human's, + # or one an ISSUE_TEMPLATE applied. A prefix rule fires unconditionally, so + # "fix: ..." on an issue already labelled `enhancement` would otherwise add + # `bug` beside it. This covers every max-1 tier (type, priority, status, + # meta, scope), not just type. + | ( [ $R.tier_max | to_entries[] | select(.value == 1) | .key ] + | map(. as $t | select($have | any(($R.tier_of[.] // "?") == $t))) + ) as $lockedtiers + | ($out | map(select(($R.tier_of[.] // "?") as $t | ($lockedtiers | index($t)) | not))) as $out + # A rule must actually have FIRED: keyword-area hits alone are not enough. + | if ($matched | not) then [] + # a type is mandatory + elif ((($out + $have) | any(. as $x | $types | index($x))) | not) then [] + else ($out | sort) end; + +classify(.; $title; $have) | .[] diff --git a/.github/workflows/label-triage.yml b/.github/workflows/label-triage.yml new file mode 100644 index 0000000..9886e92 --- /dev/null +++ b/.github/workflows/label-triage.yml @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Label Triage + +# Classify newly-filed issues against the estate label taxonomy. +# +# The sweep that established the taxonomy is a one-off; this is what stops it +# decaying. Without it every new issue arrives unlabelled and the 55%-unlabelled +# state rebuilds itself. +# +# ⚠ NO `uses:` ANYWHERE, DELIBERATELY. The estate enforces +# .github/workflows/actions.lock, which is keyed BY WORKFLOW PATH: a workflow +# the lock does not list is rejected before any step runs (startup_failure, and +# therefore no check run at all). A dispatched workflow lands in repos whose +# lock has not been regenerated, so it must not depend on any action. +# +# ⚠ THE CLASSIFIER IS jq, NOT PYTHON. Python is fully banned estate-wide -- the +# `governance / Language / package anti-pattern policy` gate runs +# `git ls-files '*.py'` and fails the PR. Shipping a .py into 416 repos would +# mean shipping an exemption into 416 repos. jq is preinstalled on every GitHub +# runner, is not banned, and needs no action. +# +# Deliberately conservative: +# - ADDITIVE ONLY. It never removes a label and never overrides a human's +# classification: anything already on the issue is passed in via `have` and +# is never re-suggested, and the classifier stays out of any max-1 tier the +# issue already carries a label in. +# - SILENT WHEN UNSURE. Nothing is printed unless a prefix, bracket or type +# rule actually fired. Roughly 70% of the historical corpus classified this +# way; the rest is meant to reach a human. +# - NEVER FAILS THE ISSUE. Every step is best-effort; a missing payload or an +# API hiccup exits 0 rather than leaving a red mark on someone's bug report. + +on: + issues: + types: [opened, reopened] + workflow_dispatch: + inputs: + issue: + description: "Issue number to (re)classify" + required: true + +permissions: + issues: write + contents: read + +jobs: + triage: + runs-on: ubuntu-latest + steps: + - name: Classify and label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUM: ${{ github.event.issue.number || inputs.issue }} + run: | + set -uo pipefail + work=$(mktemp -d); RULES=$work/rules.json; SCRIPT=$work/classify.jq + + # fetch instead of checking out -- no action means no lock entry to drift + gh api "repos/$GITHUB_REPOSITORY/contents/.github/label-classifier.json?ref=$GITHUB_SHA" \ + --jq '.content' 2>/dev/null | base64 -d > "$RULES" || true + gh api "repos/$GITHUB_REPOSITORY/contents/.github/scripts/classify-issue.jq?ref=$GITHUB_SHA" \ + --jq '.content' 2>/dev/null | base64 -d > "$SCRIPT" || true + if [[ ! -s "$RULES" || ! -s "$SCRIPT" ]]; then + echo "no classifier payload in this repo - nothing to do" + exit 0 + fi + + TITLE=$(gh issue view "$NUM" -R "$GITHUB_REPOSITORY" --json title --jq .title) || exit 0 + echo "issue #$NUM: $TITLE" + + # Labels this repo actually defines. --limit 1000 is GitHub's real + # per-repo ceiling; the default of 30 would silently hide most of the + # taxonomy. Fetched BEFORE the label read below so that read stays as + # close to the write as possible. + mapfile -t DEFINED < <(gh label list -R "$GITHUB_REPOSITORY" --limit 1000 \ + --json name --jq '.[].name' 2>/dev/null) + + # Labels already present; a human's work is never overridden. Read + # HERE rather than earlier: every API call between this read and the + # edit below widens a window in which someone could add a type label + # and get a second one back from us. Only the local jq call is inside it. + HAVE=$(gh issue view "$NUM" -R "$GITHUB_REPOSITORY" \ + --json labels --jq '[.labels[].name]' 2>/dev/null) || HAVE='[]' + [[ -n "$HAVE" ]] || HAVE='[]' + echo "already has: $HAVE" + + mapfile -t ADD < <(jq -r --arg title "$TITLE" --argjson have "$HAVE" \ + -f "$SCRIPT" "$RULES" 2>/dev/null) + if [[ ${#ADD[@]} -eq 0 || -z "${ADD[0]:-}" ]]; then + echo "no confident classification - leaving for a human" + exit 0 + fi + + apply=() + for want in "${ADD[@]}"; do + for def in "${DEFINED[@]}"; do + if [[ "$want" == "$def" ]]; then apply+=("$want"); break; fi + done + done + if [[ ${#apply[@]} -eq 0 ]]; then + echo "classified as ${ADD[*]} but this repo defines none of them - run the label sync" + exit 0 + fi + + printf 'applying: %s\n' "${apply[*]}" + # Build the arguments as an ARRAY. The previous form was an unquoted + # command substitution, so the shell re-split its output on spaces and + # a label name containing whitespace would arrive as several broken + # arguments. No canonical label contains a space today, which is + # exactly why this would have failed quietly the first time one did. + # (Also clears actionlint SC2046.) + edit_args=() + for lab in "${apply[@]}"; do edit_args+=(--add-label "$lab"); done + gh issue edit "$NUM" -R "$GITHUB_REPOSITORY" "${edit_args[@]}" \ + || echo "label apply failed - not failing the run" + exit 0 diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 0000000..c80b676 --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Labels + +# Applies the canonical estate label set from .github/labels.json. +# +# Additive and idempotent by design: it CREATES missing labels and UPDATES +# colour/description drift. It never deletes, and it never touches a label in +# the `frozen` list -- those are applied by Dependabot / PR automation, or are +# wired into triage.yml's exempt-issue-labels, and renaming them breaks things. +# +# jq is preinstalled on GitHub runners; PyYAML is not, which is why the payload +# is JSON rather than YAML. +# +# ⚠ NO `uses:` ANYWHERE, DELIBERATELY. The estate enforces +# .github/workflows/actions.lock, which is keyed BY WORKFLOW PATH: a workflow +# the lock does not list is rejected before any step runs (startup_failure, and +# therefore no check run at all). A dispatched workflow lands in repos whose +# lock has not been regenerated, so it must not depend on any action. + +on: + workflow_dispatch: + push: + paths: + - '.github/labels.json' + schedule: + - cron: "23 4 1 * *" # monthly drift repair + +permissions: + issues: write + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Apply canonical labels + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # ⚠ LOAD-BEARING. This workflow deliberately does not check the repo + # out (no `uses:`, so no actions.lock entry can drift), which means + # `gh label create` / `gh label edit` have no git remote to infer a + # target from. Without GH_REPO every mutation fails, and because the + # errors used to be discarded the step still exited 0 reporting + # "created=0 updated=0" -- a silent, estate-wide no-op. + GH_REPO: ${{ github.repository }} + run: | + set -uo pipefail + work=$(mktemp -d); PAYLOAD=$work/labels.json + + # fetch instead of checking out -- no action means no lock entry to drift + gh api "repos/$GITHUB_REPOSITORY/contents/.github/labels.json?ref=$GITHUB_SHA" \ + --jq '.content' 2>/dev/null | base64 -d > "$PAYLOAD" || true + [ -s "$PAYLOAD" ] || { echo "no .github/labels.json - nothing to do"; exit 0; } + + mapfile -t FROZEN < <(jq -r '.frozen[]' "$PAYLOAD") + created=0; updated=0; skipped=0; failed=0 + + existing=$(gh api "repos/$GITHUB_REPOSITORY/labels" --paginate \ + --jq '.[] | [.name, .color, (.description // "")] | @tsv') + + while IFS=$'\t' read -r name color desc; do + [ -z "$name" ] && continue + frozen=0 + for f in "${FROZEN[@]}"; do [ "$f" = "$name" ] && frozen=1 && break; done + + cur=$(printf '%s\n' "$existing" | awk -F'\t' -v n="$name" '$1==n{print;exit}') + if [ -z "$cur" ]; then + # A MISSING label is created even when frozen. "Frozen" protects a + # label's DEFINITION from being renamed or recoloured -- it was + # never meant to stop the label existing. Skipping creation broke + # `security`, the one canonical label that is also frozen: it was + # absent from 10 of 12 sampled repos, and label-triage drops any + # label the repo does not define, so every `security` finding was + # silently discarded estate-wide. + if err=$(gh label create "$name" --color "$color" \ + --description "$desc" 2>&1 >/dev/null); then + created=$((created+1)); sleep 0.4 + else + echo " create failed: $name -- ${err:-unknown}"; failed=$((failed+1)) + fi + else + # Present AND frozen: leave it exactly as it is. + if [ "$frozen" -eq 1 ]; then skipped=$((skipped+1)); continue; fi + ccol=$(cut -f2 <<<"$cur"); cdesc=$(cut -f3- <<<"$cur") + if [ "${ccol,,}" != "${color,,}" ] || [ "$cdesc" != "$desc" ]; then + if err=$(gh label edit "$name" --color "$color" \ + --description "$desc" 2>&1 >/dev/null); then + updated=$((updated+1)); sleep 0.4 + else + echo " edit failed: $name -- ${err:-unknown}"; failed=$((failed+1)) + fi + fi + fi + done < <(jq -r '.labels[] | [.name, .color, .description] | @tsv' "$PAYLOAD") + + echo "created=$created updated=$updated frozen-skipped=$skipped failed=$failed" + + # Fail ONLY on the misconfiguration shape: work was attempted, every + # attempt failed. That is the silent-no-op signature. A single flaky + # label must not turn the whole estate's CI red. + if [ "$failed" -gt 0 ] && [ "$((created + updated))" -eq 0 ]; then + echo "every label mutation failed - the sync did nothing. Check GH_REPO and token scope." + exit 1 + fi + exit 0