Playbook: Fix GitHub Milestones (standard set + cleanup)

When to use

  • repo-consistency-audit.md reports 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

MilestonePurpose
TriageNew issues awaiting prioritization
DesignIssue in design/planning phase
In ProgressActively being implemented
ReviewPR open, awaiting review
StagingDeployed to staging, awaiting sign-off
MainMerged to main / done
QueueOptional/manual — see note below

Future — optional, for deferred items with no schedule.

Queuep24-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 into dev_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-requestWIP → (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)

RepoAction needed
et-operational-platformClose 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-AgencyDelete corrupted System.Collections.Hashtable.title milestone
amazon-kdp-tangoAdd all standard milestones (only RC exists)
p24-infraAll 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.