diff --git a/.github/labels.yaml b/.github/labels.yaml index 314743b5..7c35809f 100644 --- a/.github/labels.yaml +++ b/.github/labels.yaml @@ -73,4 +73,12 @@ - name: agent/saffron-escalated color: "0052cc" description: Stable escalated-lane Saffron worker. -# Repo-specific labels can be appended below. Unknown labels are not pruned. +# Blocked / infra +# The umbrella label is the only managed infra-blocked label. Per-attempt and +# per-model variants (blocked/infra-attempt/N, blocked/infra-model/name) are +# unmanaged drift emitted by an external loop and are pruned by the label-sync +# workflow. Do NOT add them here. See AGENTS.md "Label hygiene". +- name: blocked/infra + color: "e11d21" + description: Progress is blocked on infrastructure (umbrella; no per-attempt variants). +# Repo-specific labels can be appended below. diff --git a/.github/workflows/label-sync.yaml b/.github/workflows/label-sync.yaml index 2be05b1b..19453424 100644 --- a/.github/workflows/label-sync.yaml +++ b/.github/workflows/label-sync.yaml @@ -12,7 +12,15 @@ on: default: false push: branches: ["main"] - paths: [".github/labels.yaml"] + paths: + - ".github/labels.yaml" + - ".github/workflows/label-sync.yaml" + # Recurring guard: an external emitter (bridge / pr-followup path) keeps + # creating fresh blocked/infra-attempt/ and blocked/infra-model/ + # labels that are never declared in labels.yaml. Prune them on a schedule so + # the kanban filter UI does not accumulate 100+ dead labels. See #916. + schedule: + - cron: "23 4 * * 1" permissions: contents: read @@ -24,6 +32,7 @@ jobs: permissions: contents: read issues: write + labels: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -37,3 +46,27 @@ jobs: config-file: .github/labels.yaml delete-other-labels: false dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + + # The sync action above only manages labels declared in .github/labels.yaml + # and (with delete-other-labels: false) never prunes unmanaged ones. An + # external emitter keeps creating fresh blocked/infra-attempt/ and + # blocked/infra-model/ labels, so prune exactly those shapes here. + # The blocked/infra umbrella is kept. See #916. + - name: Prune unmanaged blocked/infra-attempt/* and blocked/infra-model/* labels + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + labels="$(gh label list --limit 500 --json name \ + | jq -r '.[].name' \ + | grep -E '^blocked/infra-(attempt|model)/' || true)" + if [ -z "$labels" ]; then + echo "No unmanaged blocked/infra-attempt/* or blocked/infra-model/* labels to prune." + exit 0 + fi + while IFS= read -r label; do + [ -n "$label" ] || continue + echo "Deleting unmanaged label: $label" + gh label delete "$label" --yes \ + || echo "::warning::Could not delete $label (may be in use)" + done <<< "$labels" diff --git a/AGENTS.md b/AGENTS.md index f3ea7f6f..8fcab3a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,24 @@ Labels follow a `category/value` pattern: - **Priority**: `priority/p0` through `priority/p3` - **Type**: `type/bug`, `type/feature`, `type/chore`, `type/research`, `type/security` +#### Label hygiene + +`.github/labels.yaml` is the source of truth for managed labels. The label-sync +workflow (`.github/workflows/label-sync.yaml`) keeps the repo in sync with it. + +- **Umbrella labels are allowed; per-attempt / per-model variants are not.** + `blocked/infra` is the single managed label for infrastructure-blocked work. + Do NOT create `blocked/infra-attempt/` or `blocked/infra-model/` + labels: an external emitter (bridge / pr-followup path) used to write a fresh + one per failed GHA attempt, accumulating ~95 dead labels that polluted every + kanban label filter, repo add-label call, and audit view (#916). +- The label-sync workflow prunes exactly the `blocked/infra-attempt/*` and + `blocked/infra-model/*` shapes (on push to main touching the label files and + on a weekly schedule) and keeps the `blocked/infra` umbrella. +- If you need to distinguish infra failure modes, encode them in the issue + body / audit log, not in a new label. A single repo failure mode does not + need N unique identifiers. + ### Issue Execution Lane Classification diff --git a/src/app/api/issues/label/route.test.ts b/src/app/api/issues/label/route.test.ts index 1e119474..5bfc3b98 100644 --- a/src/app/api/issues/label/route.test.ts +++ b/src/app/api/issues/label/route.test.ts @@ -201,6 +201,66 @@ describe("POST /api/issues/label", () => { expect(mockAddLabel).toHaveBeenCalledTimes(30); }); + it("rejects adding a blocked/infra-attempt/N label (unmanaged drift, #916)", async () => { + const res = await POST( + makeRequest({ ...validBody, label: "blocked/infra-attempt/42", action: "add" }), + ); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("blocked/infra-attempt/42"); + expect(data.error).toContain("blocked/infra"); + expect(mockAddLabel).not.toHaveBeenCalled(); + expect(mockRemoveLabel).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockAuditCreate).not.toHaveBeenCalled(); + }); + + it("rejects adding a blocked/infra-model/name label (unmanaged drift, #916)", async () => { + const res = await POST( + makeRequest({ ...validBody, label: "blocked/infra-model/llama-3", action: "add" }), + ); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("blocked/infra-model/llama-3"); + expect(mockAddLabel).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("allows adding the blocked/infra umbrella label", async () => { + const res = await POST( + makeRequest({ ...validBody, label: "blocked/infra", action: "add" }), + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.labels).toEqual(["bug", "blocked/infra"]); + expect(mockAddLabel).toHaveBeenCalledWith("misospace/dispatch", 42, "blocked/infra"); + }); + + it("allows removing a blocked/infra-attempt/N label (cleanup of existing drift)", async () => { + mockFindUnique.mockResolvedValue({ + ...validIssue, + labels: ["bug", "blocked/infra-attempt/7"], + } as never); + const res = await POST( + makeRequest({ + ...validBody, + label: "blocked/infra-attempt/7", + action: "remove", + }), + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.labels).toEqual(["bug"]); + expect(mockRemoveLabel).toHaveBeenCalledWith( + "misospace/dispatch", + 42, + "blocked/infra-attempt/7", + ); + expect(mockAddLabel).not.toHaveBeenCalled(); + }); + it("returns 400 when required fields are missing", async () => { const res = await POST(makeRequest({ issueId: "issue-1" })); expect(res.status).toBe(400); diff --git a/src/app/api/issues/label/route.ts b/src/app/api/issues/label/route.ts index da81aefd..33a91c44 100644 --- a/src/app/api/issues/label/route.ts +++ b/src/app/api/issues/label/route.ts @@ -62,6 +62,19 @@ export async function POST(request: Request) { const op = action === "remove" ? "remove" : "add"; const labelName = label.trim(); + // Guard (#916): an external emitter used to auto-create a fresh + // blocked/infra-attempt/ / blocked/infra-model/ label per failed + // GHA attempt, accumulating ~95 dead labels. The GitHub API auto-creates + // missing labels on add, so reject that shape here before it can be + // created. The blocked/infra umbrella is allowed, and removal is always + // allowed so existing drift can still be cleaned up. + if (op === "add" && /^blocked\/infra-(attempt|model)\//.test(labelName)) { + return errorResponse( + `Label "${labelName}" is not allowed: per-attempt/per-model infra labels are unmanaged drift. Use the "blocked/infra" umbrella label instead.`, + 400, + ); + } + const actorName = getAuthorizedActor( auth, request,