Playbook: Fix GitHub Milestones (standard set + cleanup)
When to use
repo-consistency-audit.mdreports missing standard milestones- Duplicate milestones detected (e.g.
triage+Triage) - Corrupted milestone names (e.g.
System.Collections.Hashtable.title— PowerShell serialization bug) - Legacy milestones left over from old pipeline versions
- Worker queue sets wrong milestone because expected name doesn’t exist
Standard milestone set
| Milestone | Purpose |
|---|---|
Triage | New issues awaiting prioritization |
Design | Issue in design/planning phase |
In Progress | Actively being implemented |
Review | PR open, awaiting review |
Staging | Deployed to staging, awaiting sign-off |
Main | Merged to main / done |
Queue | Optional/manual — see note below |
Future — optional, for deferred items with no schedule.
Queue — p24-infra only, not part of this cross-repo standard set. Two separate pipelines can
touch it, with different levels of automation:
- dispatch-to-queue.yml / dev-issue pipeline — set automatically by
dispatch-to-queue.yml/the meta-dispatcher CF Worker once an issue is actually inserted intodev_r_worker_queue, before a worker claims it (see.claude/task-playbooks/triage.md). - infra-task-request pipeline — optional/manual only. It can be applied by hand to visually mark
an
infra-task-request-labeled issue as waiting for pickup, but no automation currently sets or clears it for this pipeline — see “infra-task-request pipeline milestone handling” below for what actually drives this pipeline’s state.
Other repos in this list have no dispatch pipeline that sets it — do not add it to $standard below.
infra-task-request pipeline milestone handling
The infra-task-request pipeline (triggered by .github/workflows/infra-task-request.yml on the
infra-task-request label, executed by infra/agent-prompts/infra-task-request-worker.md, claimed
by scripts/spawn-infra-task-worker.sh) tracks its real state through its label set, not the
milestone field: infra-task-request → WIP → (human-action | infra-done).
As of the #4825 fix, the milestone is auto-advanced exactly once: scripts/spawn-infra-task-worker.sh
sets it to In Progress right after adding the WIP,ai-dev-queued labels at claim time (non-fatal —
a milestone API failure never blocks the claim). No further milestone transition happens when the
issue is blocked (human-action label) or completed (infra-done label / issue close) — the labels
already communicate that status on their own, and duplicating it onto the milestone field was a
deliberate scope decision made when fixing #4825, not an oversight. infra/agent-prompts/infra-task-request-worker.md
was intentionally left untouched: the claim-time milestone transition belongs in the spawn script,
the single source of truth for “a worker has claimed this issue,” not duplicated into the prompt the
worker itself follows.
Diagnosis
$repo = "radieu/<repo>"
$ms = gh api "repos/$repo/milestones?state=all&per_page=50" --jq "[.[] | {title,number,state}]" | ConvertFrom-Json
$ms | Format-Table title, number, state
# Check for duplicates (case-insensitive)
$dupes = $ms | Group-Object { $_.title.ToLower() } | Where-Object { $_.Count -gt 1 }
if ($dupes) { Write-Host "DUPLICATES:" ; $dupes | ForEach-Object { $_.Group | Select-Object title,number } }
# Check for missing standard milestones
$standard = @("Triage","Design","In Progress","Review","Staging","Main")
$have = $ms.title
$miss = $standard | Where-Object { $_ -notin $have }
"Missing: $($miss -join ', ')"Fix 1 — Add missing standard milestones (idempotent)
$repo = "radieu/<repo>"
$standard = @("Triage","Design","In Progress","Review","Staging","Main")
$existing = (gh api "repos/$repo/milestones?state=all&per_page=50" --jq "[.[].title]" | ConvertFrom-Json)
foreach ($ms in $standard) {
if ($ms -notin $existing) {
gh api "repos/$repo/milestones" -X POST -f title="$ms" | Out-Null
Write-Host "Created: $ms"
} else {
Write-Host "OK (exists): $ms"
}
}Fix 2 — Close duplicate/legacy milestones
GitHub does not allow deleting milestones via API if issues are assigned to them. Safe approach: close (state=closed) the duplicate, reassign any issues to the canonical one.
$repo = "radieu/<repo>"
# Get the duplicate's number
$dupNum = gh api "repos/$repo/milestones?state=all&per_page=50" `
--jq "[.[] | select(.title == \"triage\")] | .[0].number" 2>$null
# Close it
if ($dupNum) {
gh api "repos/$repo/milestones/$dupNum" -X PATCH -f state=closed | Out-Null
Write-Host "Closed milestone #$dupNum (triage)"
}Repeat for each legacy milestone name. List of known legacy names to close:
triage (lowercase dup of Triage), To Review, Done, Dev, Blocked,
pending-for-ai, ai-processing, rc-testing, human-todo, rc1, RC, RC1
Bulk close all legacy milestones
$repo = "radieu/<repo>"
$legacy = @("triage","To Review","Done","Dev","Blocked","pending-for-ai",
"ai-processing","rc-testing","human-todo","rc1","RC","RC1")
$all = gh api "repos/$repo/milestones?state=all&per_page=100" | ConvertFrom-Json
foreach ($ms in $all) {
if ($ms.title -in $legacy) {
gh api "repos/$repo/milestones/$($ms.number)" -X PATCH -f state=closed | Out-Null
Write-Host "Closed: $($ms.title) (#$($ms.number))"
}
}Fix 3 — Delete corrupted milestone
PowerShell serialization bug creates milestones named System.Collections.Hashtable.title.
These have no issues assigned, safe to delete:
$repo = "radieu/<repo>"
$badNum = gh api "repos/$repo/milestones?state=all&per_page=50" `
--jq "[.[] | select(.title | startswith(\"System.\"))] | .[0].number" 2>$null
if ($badNum) {
gh api "repos/$repo/milestones/$badNum" -X DELETE | Out-Null
Write-Host "Deleted corrupted milestone #$badNum"
}Affected repos (as of 2026-06-28)
| Repo | Action needed |
|---|---|
| et-operational-platform | Close legacy: triage (dup), To Review, Done, Dev, Blocked, pending-for-ai, ai-processing, rc-testing, human-todo, rc1, RC; keep: Triage, Design, In Progress, Review, Staging, Main, Future |
| Art-Agency | Delete corrupted System.Collections.Hashtable.title milestone |
| amazon-kdp-tango | Add all standard milestones (only RC exists) |
| p24-infra | All standard milestones present (Triage-Human + future milestones are intentional) |
Prevention
The corrupted System.Collections.Hashtable.title bug was caused by passing a PowerShell hashtable
directly to gh api -f. Always serialize with ConvertTo-Json first or use the --jq flag.
# WRONG (causes corrupted milestone names)
gh api repos/$repo/milestones -X POST -f title=@{title="Triage"}
# CORRECT
gh api repos/$repo/milestones -X POST -f title="Triage"Escalation
If milestone can’t be deleted because issues are assigned: first move those issues to the
canonical milestone (gh issue edit <N> --milestone "Triage" --repo $repo), then delete.
If duplicate names are blocking worker: rename the legacy one to _archived-<name> to keep
history while removing the name collision.