"Sequentially merge a set of reviewed PRs, handling branch protection's \"must be up-to-date\" requirement by updating each branch after the previous merge."
Install
npx skillscat add blamechris/chroxy/claude-skills-batch-merge Install via the SkillsCat registry.
/batch-merge
Sequentially merge a set of reviewed PRs, handling branch protection's "must be up-to-date" requirement by updating each branch after the previous merge.
Arguments
$ARGUMENTS- Space-separated PR numbers,allto merge all open PRs targeting main (sorted by number), or--dry-runto preview without merging.- Examples:
1570 1571 1572,all,1570 1571 --dry-run
- Examples:
Instructions
Phase 0: Build Merge Queue
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
# Parse arguments
PR_NUMS=()
DRY_RUN=false
for arg in $ARGUMENTS; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
all) PR_NUMS=($(gh pr list --base main --state open --json number --jq '.[].number | tostring' | sort -n)) ;;
\#*) PR_NUMS+=("${arg#\#}") ;;
*) PR_NUMS+=("$arg") ;;
esac
doneValidate each PR: must be OPEN, targeting main, not draft. Remove invalid entries and warn.
Display the queue for user confirmation (this is the ONLY confirmation point — after approval, the entire loop runs autonomously):
## Merge Queue ({N} PRs)
| # | PR | Title | CI | Copilot | Status |
|---|-----|-------|----|---------|--------|
| 1 | #1570 | test(combat): Assert carrier bay... | — | — | Queued |
| 2 | #1571 | test(combat): Add dodge roll... | — | — | Queued |If --dry-run, state that no merges will be performed.
Phase 1: Pre-Flight Check
Before entering the merge loop, pre-check all PRs to surface blockers early. For each PR:
# CI status
gh pr checks ${PR_NUM} --json name,state \
--jq '[.[] | select(.state != "SUCCESS" and .state != "SKIPPED")] | length'
# Copilot review presence
# DISMISSED is excluded on purpose: after `dismiss_stale_reviews` fires, a
# superseded review is still returned by this endpoint, so counting it reports
# the gate as satisfied by a review of code that no longer exists.
gh api repos/${REPO}/pulls/${PR_NUM}/reviews \
--jq '[.[] | select(.user.login == "copilot-pull-request-reviewer[bot]")
| select(.state != "DISMISSED")] | length'Update the progress table with pre-flight results. This is informational — does not block the loop.
Phase 2: Sequential Merge Loop
Process PRs in order. For each PR:
Step 2a: Check CI
The required-check roster is derived here, never typed here.scripts/lib/contributing-roster.mjs is this repo's one parse of
CONTRIBUTING.md's required-checks bullet — the same implementationpackages/server/tests/contributing-required-checks.test.js pins againstci.yml's job names and scripts/check-required-contexts.sh diffs against the
live branch protection. A list typed into this file instead is the "hardcoded
list beside a growing set" entry in docs/false-safety-guards.md, and this step
had already failed exactly that way (#7503): the names it carried — Run Tests
and Validate Project — matched zero of the 23 contexts a real chroxy PR
produces, so the filter selected no rows, "nothing failing" read as "all green",
and the gate passed on a red PR.
# One required context per line. Run from the repo root: only the PATH to the
# parser is relative - it resolves CONTRIBUTING.md against its own module URL.
# REFUSE rather than gate on a guess.
REQUIRED_CHECKS=$(node scripts/lib/contributing-roster.mjs) || {
echo "REFUSE: cannot read the required-checks roster - not gating blind" >&2
exit 2
}
# Extraction runs through gh's BUILT-IN `--jq` (gojq, compiled into gh), like
# every other extraction in this playbook - never an external `jq` binary.
# macOS ships no jq, and a classifier whose failure yields an empty blocker list
# is fail-open: #7503's own shape one layer down, with "cannot check" reading as
# "nothing to check" (#7540 review, and Copilot's thread on the same lines).
#
# `gh pr checks` exits non-zero when a check is red or still running. The rows
# are still on stdout and classifying them is this step's whole job, so gh's
# exit code is deliberately not the gate. An EMPTY stdout is: no rows must never
# read as nothing wrong, and it is equally what a gh auth failure, a network
# error and a broken --jq program produce.
ROWS=$(gh pr checks "${PR_NUM}" --json name,state --jq '.[] | [.name, .state] | @tsv')
[ -n "$ROWS" ] || {
echo "REFUSE: no check rows for #${PR_NUM} - not gating blind" >&2
exit 2
}
# Classification is pure shell: there is no third binary here whose failure
# could empty the blocker list. It counts what it classified, so "examined
# nothing" cannot report clean either.
#
# A required context MISSING from the payload BLOCKS. Absence is what a stale or
# misspelled roster entry produces, and it is equally what a renamed ci.yml job
# produces (#7191) - branch protection wedges on both, so neither may pass here.
BLOCKERS=""
GATED=0
TAB=$(printf '\t')
while IFS= read -r name; do
[ -n "$name" ] || continue
GATED=$((GATED + 1))
seen=0
bad=""
while IFS="$TAB" read -r rname rstate; do
[ "$rname" = "$name" ] || continue
seen=1
# SUCCESS and SKIPPED are the ONLY accepted states. FAILURE, ERROR,
# CANCELLED, TIMED_OUT, PENDING, QUEUED, IN_PROGRESS and any state GitHub
# adds later all fall to the else arm and block. Listing what passes, not
# what fails, is what keeps a new state from being accepted by default.
case "$rstate" in
SUCCESS|SKIPPED) ;;
*) bad="${bad}${bad:+,}${rstate}" ;;
esac
done <<<"$ROWS"
if [ "$seen" -eq 0 ]; then
BLOCKERS="${BLOCKERS}${BLOCKERS:+; }${name}=MISSING"
elif [ -n "$bad" ]; then
BLOCKERS="${BLOCKERS}${BLOCKERS:+; }${name}=${bad}"
fi
done <<<"$REQUIRED_CHECKS"
# Classifying nothing is not a pass.
[ "$GATED" -gt 0 ] || {
echo "REFUSE: classified 0 required contexts - not gating blind" >&2
exit 2
}An empty BLOCKERS after a non-zero GATED is the only pass — the count is
the positive signal, and every way of failing to look reaches a REFUSE above
rather than an empty list. A non-empty BLOCKERS names every required context
that is missing, red, or still running:
=PENDING/=QUEUED/=IN_PROGRESS— poll every 30s for up to 3
minutes, re-running the block each time.=FAILURE/=ERROR/ any other state — run/fix-ci ${PR_NUM}. If
fixed, continue. If escalated, mark the PRSkippedand continue to the next.=MISSING— re-poll once after the pending window before treating it
as final, then mark the PRSkippedwith the reason and continue to the next
(Critical Rule 6: one stale PR must not stop the batch). Absence is not
automatically terminal — a push can produce no workflow run at all for the
new head, and every tool renders that as pending rather than absent. This repo
has an instance: on PR #7023 the concurrency group was never freed by a
run stuck on an offline runner, the next push created zero runs, andgh pr checksreported "no checks reported" indefinitely. So assert a run
exists for the head before concluding:gh run list --branch "$(gh pr view ${PR_NUM} --json headRefName -q .headRefName)" \ --limit 5 --json headSha,name,status,conclusionIf the newest run's
headShais not the PR's head, nothing is running and
waiting longer cannot help. If a run does exist for the head and the context
is still absent, it is terminal: aci.ymljob was renamed without updating
CONTRIBUTING.md and the live protection, and branch protection will refuse the
merge however long you wait.
Note that Phase 1's pre-flight count is a different expression on purpose — it
quantifies over every check the PR has, required or not, and is explicitly
informational. This step is the gate.
Step 2b: Check Copilot Review
COPILOT_STATUS=$(gh api repos/${REPO}/pulls/${PR_NUM}/reviews \
--jq '[.[] | select(.user.login == "copilot-pull-request-reviewer[bot]")
| select(.state != "DISMISSED")] |
if length == 0 then "NOT_FOUND"
elif any(.[]; .state == "PENDING") then "IN_PROGRESS"
else "COMPLETED" end')Copilot review must be present before merge. This is the quality gate.
- COMPLETED: Proceed.
- IN_PROGRESS: Poll every 30s, max 5 min.
- NOT_FOUND + PR < 8 min old: Poll every 30s, max 8 min. Copilot takes 3-5 min to start.
- NOT_FOUND + PR >= 8 min old: Proceed with warning (Copilot won't come for old PRs).
Compute the age rather than estimating it — the branch above is only reproducible
if both operators derive it the same way:
PR_AGE_MIN=$(gh pr view ${PR_NUM} --json createdAt \
--jq '((now - (.createdAt | fromdateiso8601)) / 60) | floor')Step 2c: Address Unaddressed Copilot Comments
Check for Copilot inline comments without replies:
# Get all inline comments
ALL_COMMENTS=$(gh api repos/${REPO}/pulls/${PR_NUM}/comments --paginate)
# Find Copilot comments without a reply from us
WORKFLOW_USER=$(gh api user --jq .login)
# Copilot-authored top-level comments (not replies) that have no reply from us.
# Scope to the Copilot bot so this step only processes Copilot threads, as the
# heading claims — human review comments are out of scope for batch-merge.
UNREPLIED=$(echo "$ALL_COMMENTS" | jq --arg user "$WORKFLOW_USER" '
. as $all
| [ $all[]
| select(.in_reply_to_id == null)
| select(.user.login == "copilot-pull-request-reviewer[bot]")
| select(.id as $id
| ($all | any(.[]; .in_reply_to_id == $id and .user.login == $user)) | not) ]
')For each unreplied comment, handle using the 3-outcome model from /check-pr:
- FIX — Fix the issue, commit, reply with before/after
- FALSE POSITIVE — Reply explaining why no change needed
- DEFER — Create follow-up issue, reply with issue link
CRITICAL: If any fix commits are pushed, dismiss_stale_reviews will invalidate the Copilot review. You MUST re-enter Step 2b and wait for a fresh Copilot review before proceeding to merge.
Step 2d: Merge
if [ "$DRY_RUN" = true ]; then
echo "DRY RUN: Would merge PR #${PR_NUM}"
else
gh pr merge ${PR_NUM} --squash --delete-branch
fiIf merge fails, apply the blocker decision tree (Phase 3).
Step 2e: Update Next PR Branch
This step and 2f apply only under strict: true branch protection ("Require
branches to be up to date before merging"). Under strict: false a merge does
not make the remaining PRs unmergeable, and updating each one just spends a CI
run. chroxy main is strict: false — recorded indocs/decisions/2026-06-02-overnight-marathon.md, where a 12-PR batch skipped
2e and 2f on exactly that basis. Derive the setting rather than trusting that
sentence: it is per-repo and per-branch, and this playbook is installed in more
than one repo.
# Fail CLOSED: anything other than a definite `false` is treated as strict.
# Reading protection needs repo-admin scope, so an operator without it gets the
# safe answer - a needless update-branch costs a CI run, a skipped necessary one
# wedges the merge.
STRICT=$(gh api "repos/${REPO}/branches/main/protection" \
--jq '.required_status_checks.strict' 2>/dev/null) || STRICT=true
[ "$STRICT" = "false" ] || STRICT=trueIf STRICT is false, skip the rest of this step and Step 2f, and go straight
to the next PR. Otherwise PR N+1 is now stale — update it:
NEXT_PR=${PR_NUMS[$((current_index + 1))]}
if [ -n "$NEXT_PR" ]; then
gh api repos/${REPO}/pulls/${NEXT_PR}/update-branch \
--method PUT \
-f expected_head_sha="$(gh pr view ${NEXT_PR} --json headRefOid -q .headRefOid)"
fiIf update-branch fails with a conflict, mark the next PR as Blocked and try the PR after that (it still needs updating since main changed).
Step 2f: Wait for CI on Updated Branch
Skipped together with 2e when STRICT is false — there is no updated branch
to wait on.
MAX_WAIT=180 # 3 minutes
INTERVAL=30
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
PENDING=$(gh pr checks ${NEXT_PR} --json state \
--jq '[.[] | select(.state == "PENDING" or .state == "QUEUED" or .state == "IN_PROGRESS")] | length')
FAILED=$(gh pr checks ${NEXT_PR} --json state \
--jq '[.[] | select(.state == "FAILURE" or .state == "ERROR")] | length')
if [ "$PENDING" = "0" ] && [ "$FAILED" = "0" ]; then
break # All checks done and passing
fi
if [ "$FAILED" != "0" ] && [ "$PENDING" = "0" ]; then
break # Failed but nothing pending — don't wait
fi
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
doneIf CI fails after update-branch, run /fix-ci ${NEXT_PR}.
Step 2g: Update Progress Table
Output the progress table after every merge. This is the user's live dashboard.
## Merge Progress ({merged}/{total})
| # | PR | Title | CI | Copilot | Merge | Notes |
|---|-----|-------|----|---------|-------|-------|
| 1 | #1570 | test(combat): Assert carrier... | PASS | Reviewed (0) | Merged | — |
| 2 | #1571 | test(combat): Add dodge roll... | PASS | Reviewed (1→fixed) | Merged | 1 fix in abc1234 |
| 3 | #1572 | test(autoload): Add Statistics... | Updating | — | Next | CI running after update-branch |
| 4 | #1573 | fix: Address silent failures... | — | — | Queued | — |Column values:
| Column | Values |
|---|---|
| CI | PASS, FAIL→fixed, FAIL→skipped, Updating, Pending, — |
| Copilot | Reviewed (N), Reviewed (N→M fixed), Pending, None (old PR), — |
| Merge | Merged, Blocked, Skipped, Next, Queued, DRY RUN |
Phase 3: Merge Blocker Decision Tree
When gh pr merge fails, classify and respond:
| Error Pattern | Action | Max Retries |
|---|---|---|
| "not up to date" / "branch is behind" | update-branch → wait CI → retry |
1 |
| "status check" / "required status" | /fix-ci → retry |
1 |
| "review" / "approval" / "dismissed" | Wait for fresh Copilot review → retry | 1 |
| "conversation" / "unresolved" / "must be resolved" | Resolve open review threads (see below), then retry | 1 |
| "conflict" / "not mergeable" | Skip immediately, report | 0 |
| "already merged" | Skip silently, note in table | 0 |
| Rate limit (403/429) | Back off 60s → retry | 2 |
| Unknown | Log full error, skip PR | 0 |
After max retries exhausted: mark PR as Skipped with reason, continue to next PR.
Unresolved review conversations. When branch protection requires conversation
resolution, gh pr merge fails with a message about unresolved conversations rather
than a status-check or review failure — easy to misclassify as Unknown and skip
opaquely. Detect it explicitly and surface it as the blocker reason. Confirm the cause
via GraphQL (REST does not expose thread state):
UNRESOLVED=$(gh api graphql -f query="
query {
repository(owner: \"${REPO%/*}\", name: \"${REPO#*/}\") {
pullRequest(number: ${PR_NUM}) {
reviewThreads(first: 100) { nodes { isResolved } }
}
}
}" --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length')If UNRESOLVED > 0, the threads were left open by the earlier review pass (reviews
should run BEFORE batch-merge — see Critical Rule 2). Resolve them with the same
GraphQL resolveReviewThread mutation /check-pr step 6b uses, then retry the merge
once. If threads cannot be resolved (e.g., a reviewer re-opened one intentionally),
mark the PR Blocked with reason "unresolved review conversations" rather than failing
silently — the user must weigh in.
Phase 4: Session Summary
After all PRs processed:
## Batch Merge Complete
**Merged:** {N}/{total} | **Skipped:** {M} | **Blocked:** {K}
| # | PR | Title | Merge | Notes |
|---|-----|-------|-------|-------|
| 1 | #1570 | test(combat): Assert carrier bay... | Merged | — |
| 2 | #1571 | test(combat): Add dodge roll... | Merged | 1 Copilot fix |
| 3 | #1572 | test(autoload): Add Statistics... | Skipped | CI timeout |
### Skipped/Blocked PRs
- **#1572**: `Server Tests` timed out after update-branch. Needs manual investigation.
### Copilot Comments Addressed During Merge
- **#1571**: 1 comment → FIX in `abc1234` (added null guard)Error Recovery
| Error | Recovery | Max Retries |
|---|---|---|
| CI failure after update-branch | /fix-ci, wait, retry merge |
1 |
| Copilot review not posted | Poll every 30s, max 8 min | 16 polls |
| Copilot review dismissed (stale) | Wait for new review cycle | 1 |
| Merge blocked (unknown) | Diagnose via gh pr checks, report |
1 |
| update-branch conflict | Skip PR, continue | 0 |
| Rate limiting | Back off 60s, retry | 2 |
| PR already merged | Skip silently | 0 |
| PR closed | Skip silently | 0 |
Critical Rules
- Sequential only — one PR at a time, so each PR's gate is evaluated against
themainit will actually land on. Where branch protection setsstrict: true
the order is additionally mandatory (PR N+1 goes stale the instant PR N lands);
chroxy main isstrict: false, so Steps 2e/2f are normally a no-op here. Step 2e
derivesSTRICTrather than assuming either way. - Never run reviews — Reviews happen BEFORE this skill. This skill only merges.
- Never use
--admin— Respect branch protections. - Progress table after every merge — User can check in anytime.
- Copilot review is a hard gate — Must be present before merge (except old PRs where Copilot won't arrive).
- Skip and continue — Never block the batch on one stuck PR.
- Idempotent — Safe to re-run. Already-merged PRs are detected and skipped.
- Handle stale reviews — Pushing fixes invalidates reviews. Wait for fresh cycle.
- Compose with
/fix-ci— Don't reinvent CI diagnosis. - No attribution — Follow project's attribution policy in any fix commits.