batch-merge
Skill8 starsChanged 9 days agoMITView on GitHub
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.
In this repository35 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- commandsSkill
- agentic-auditSkill
- agent-reviewSkill
- autonomous-dev-flowSkill
- bug-huntSkill
- catchupSkill
- check-prSkill
- commitSkill
- create-issueSkill
- create-prSkill
- decideSkill
- decompose-issueSkill
- fix-ciSkill
- full-reviewSkill
- hallucination-checkSkill
- learnSkill
- manual-testing-modeSkill
- mergeSkill
- parallel-devSkill
- prime-directiveSkill
- project-auditSkill
- pulseSkill
- qa-updateSkill
- reconSkill
- session-lifecycleSkill
- skillSkill
- smoke-formSkill
- smoke-testSkill
- start-workingSkill
- swarm-auditSkill
- tackle-issuesSkill
- tiered-delegationSkill
- visual-briefSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 4 are distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 47
If --dry-run, state that no merges will be performed.
- Distinctive: line 51
Before entering the merge loop, pre-check all PRs to surface blockers early.
- Distinctive: line 67
Update the progress table with pre-flight results.
- Very specific: line 178
If the newest run's headSha is not the PR's head, nothing is running and waiting longer cannot help.
- Distinctive: line 216
Check for Copilot inline comments without replies:
- Very specific: line 243
CRITICAL: If any fix commits are pushed, dismiss_stale_reviews will invalidate the Copilot review.
- Very specific: line 278
If STRICT is false, skip the rest of this step and Step 2f, and go straight to the next PR.
- Very specific: line 290
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).
- Very specific: line 320
If CI fails after update-branch, run /fix-ci ${NEXT_PR}.
- Very specific: line 347
When gh pr merge fails, classify and respond:
- Very specific: line 360
After max retries exhausted: mark PR as Skipped with reason, continue to next PR.
- Very specific: line 362
Unresolved review conversations: When branch protection requires conversation resolution, gh pr merge fails with a message about unresolved …
- Very specific: line 379
If UNRESOLVED > 0, the threads were left open by the earlier review pass (reviews should run BEFORE batch-merge — see Critical Rule 2).
- Distinctive: line 388
After all PRs processed:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
/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 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 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 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=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
the
mainit will actually land on. Where branch protection setsstrict: truethe 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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

