blamechris

/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."

blamechris 7 1 Updated 1mo ago
GitHub

Install

npx skillscat add blamechris/chroxy/claude-skills-batch-merge

Install via the SkillsCat registry.

SKILL.md

/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, all to merge all open PRs targeting main (sorted by number), or --dry-run to preview without merging.
    • Examples: 1570 1571 1572, all, 1570 1571 --dry-run

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
done

Validate 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 implementation
packages/server/tests/contributing-required-checks.test.js pins against
ci.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 PR Skipped and continue to the next.

  • =MISSING — re-poll once after the pending window before treating it
    as final, then mark the PR Skipped with 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, and
    gh pr checks reported "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,conclusion

    If the newest run's headSha is 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: a ci.yml job 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:

  1. FIX — Fix the issue, commit, reply with before/after
  2. FALSE POSITIVE — Reply explaining why no change needed
  3. 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
fi

If 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 in
docs/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=true

If 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)"
fi

If 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))
done

If 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

  1. Sequential only — one PR at a time, so each PR's gate is evaluated against
    the main it will actually land on. Where branch protection sets strict: true
    the order is additionally mandatory (PR N+1 goes stale the instant PR N lands);
    chroxy main is strict: false, so Steps 2e/2f are normally a no-op here. Step 2e
    derives STRICT rather than assuming either way.
  2. Never run reviews — Reviews happen BEFORE this skill. This skill only merges.
  3. Never use --admin — Respect branch protections.
  4. Progress table after every merge — User can check in anytime.
  5. Copilot review is a hard gate — Must be present before merge (except old PRs where Copilot won't arrive).
  6. Skip and continue — Never block the batch on one stuck PR.
  7. Idempotent — Safe to re-run. Already-merged PRs are detected and skipped.
  8. Handle stale reviews — Pushing fixes invalidates reviews. Wait for fresh cycle.
  9. Compose with /fix-ci — Don't reinvent CI diagnosis.
  10. No attribution — Follow project's attribution policy in any fix commits.