Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/labels.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 34 additions & 1 deletion .github/workflows/label-sync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<N> and blocked/infra-model/<name>
# 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
Expand All @@ -24,6 +32,7 @@ jobs:
permissions:
contents: read
issues: write
labels: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -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/<N> and
# blocked/infra-model/<name> 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"
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<N>` or `blocked/infra-model/<name>`
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

Expand Down
60 changes: 60 additions & 0 deletions src/app/api/issues/label/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/app/api/issues/label/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<N> / blocked/infra-model/<name> 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,
Expand Down
Loading