Merge Pull Request
Safely merges a PR after comprehensive verification, with Linear integration and automated cleanup.
Prerequisites
# Project setup + orch-monitor daemon liveness (REQUIRED — Phase 6 consumes webhook events)
if [[ -f "${CLAUDE_PLUGIN_ROOT}/scripts/check-project-setup.sh" ]]; then
"${CLAUDE_PLUGIN_ROOT}/scripts/check-project-setup.sh" || exit 1
fi
Branch Protection — Safety Rules
Read and follow the safety rules in
"${CLAUDE_PLUGIN_ROOT}/references/merge-blocker-diagnosis.md" — specifically the "Safety Rules"
section. Summary: NEVER use --admin, --force, or any flag that bypasses branch protection.
Always resolve blockers legitimately or escalate with specifics.
Configuration
Read team configuration from .catalyst/config.json:
CONFIG_FILE=".catalyst/config.json"
[[ ! -f "$CONFIG_FILE" ]] && CONFIG_FILE=".claude/config.json"
TEAM_KEY=$(jq -r '.catalyst.linear.teamKey // "PROJ"' "$CONFIG_FILE")
TEST_CMD=$(jq -r '.catalyst.pr.testCommand // "make test"' "$CONFIG_FILE")
Process:
1. Identify PR to merge
If argument provided:
- Use that PR number:
/merge_pr 123
If no argument:
# Try current branch
gh pr view --json number,url,title,state,mergeable 2>/dev/null
If no PR on current branch:
gh pr list --limit 10 --json number,title,headRefName,state
Ask: "Which PR would you like to merge? (enter number)"
2. Get PR details
gh pr view $pr_number --json \
number,url,title,state,mergeable,mergeStateStatus,\
baseRefName,headRefName,reviewDecision
Extract:
- PR number, URL, title
- Mergeable status
- Base branch (usually main)
- Head branch (feature branch)
- Review decision (APPROVED, REVIEW_REQUIRED, etc.)
3. Verify PR is open and mergeable
state=$(gh pr view $pr_number --json state -q .state)
mergeable=$(gh pr view $pr_number --json mergeable -q .mergeable)
If PR not OPEN:
❌ PR #$pr_number is $state
Only open PRs can be merged.
If not mergeable (CONFLICTING):
❌ PR has merge conflicts
Resolve conflicts first:
gh pr checkout $pr_number
git fetch origin $base_branch
git merge origin/$base_branch
# ... resolve conflicts ...
git push
Exit with error.
4. Check if head branch is up-to-date with base
# Checkout PR branch
gh pr checkout $pr_number
# Fetch latest base
base_branch=$(gh pr view $pr_number --json baseRefName -q .baseRefName)
git fetch origin $base_branch
# Check if behind
if git log HEAD..origin/$base_branch --oneline | grep -q .; then
echo "Branch is behind $base_branch"
fi
If behind:
# Auto-rebase
git rebase origin/$base_branch
# Check for conflicts
if [ $? -ne 0 ]; then
echo "❌ Rebase conflicts"
git rebase --abort
exit 1
fi
# Push rebased branch
git push --force-with-lease
If conflicts during rebase:
❌ Rebase conflicts detected
Conflicting files:
$(git diff --name-only --diff-filter=U)
Resolve manually:
1. Fix conflicts in listed files
2. git add <resolved-files>
3. git rebase --continue
4. git push --force-with-lease
5. Run /catalyst-dev:merge-pr again
Exit with error.
5. Run local tests
Read test command from config:
test_cmd=$(jq -r '.catalyst.pr.testCommand // "make test"' .catalyst/config.json)
Execute tests:
echo "Running tests: $test_cmd"
if ! $test_cmd; then
echo "❌ Tests failed"
exit 1
fi
If tests fail:
❌ Local tests failed
Fix failing tests before merge:
$test_cmd
Or skip tests (not recommended):
/catalyst-dev:merge-pr $pr_number --skip-tests
Exit with error (unless --skip-tests flag provided).
6. Diagnose and resolve merge blockers — reactive PR lifecycle
Read and follow the full workflow in
"${CLAUDE_PLUGIN_ROOT}/references/merge-blocker-diagnosis.md". The wake-up
mechanism here is the canonical "Reactive PR lifecycle" pattern from
monitor-events (Pattern 3, CTL-228) — a single wait-for that fires on
any of: PR merged, PR closed, CI failure, review changes-requested, or push
to the base branch. Each wake-up is paired with an authoritative gh api
REST re-check; the event tells the agent what changed, gh api tells it
the current truth.
Why a multi-event filter and not just github.pr.merged: most of the time
between PR-create and PR-merge is spent on CI, review, and base-branch
churn — not waiting on a clean merge to land. Subscribing only to the merge
event means the agent learns nothing from a check failure or a
changes-requested review until the 600-second timeout fires, at which point
it falls back to REST polling via gh api. The disjunctive filter restores
event-driven dispatch for those cases.
# Two-phase compliant cadence loop — see [[wait-for-github]]. The 600s timeout
# serves as a fallback cadence; the authoritative REST check runs on every wake-up.
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
BASE_BRANCH=$(gh api "repos/${REPO}/pulls/${pr_number}" --jq '.base.ref')
ITER=0
MAX_ITER=20
while [ $ITER -lt $MAX_ITER ]; do
ITER=$((ITER + 1))
# Reactive multi-event subscription. wait-for is a no-op on event arrival;
# on timeout we fall through to the authoritative re-check below. The
# 600-second timeout is the fallback cadence when no events arrive (e.g.
# daemon down).
EVENT_JSON=$(catalyst-events wait-for \
--filter '
(.attributes."event.name" == "github.pr.merged" and .attributes."vcs.pr.number" == '"$pr_number"') or
(.attributes."event.name" == "github.pr.closed" and .attributes."vcs.pr.number" == '"$pr_number"') or
(.attributes."event.name" == "github.check_suite.completed"
and (.body.payload.prNumbers // [] | index('"$pr_number"') != null)
and (.attributes."cicd.pipeline.run.conclusion" == "failure" or .attributes."cicd.pipeline.run.conclusion" == "timed_out")) or
(.attributes."event.name" == "github.pr_review.submitted"
and .attributes."vcs.pr.number" == '"$pr_number"'
and (.body.payload.state == "changes_requested"
or (.body.payload.state == "commented" and (.body.payload.author.type // "") == "Bot"))) or
(.attributes."event.name" == "github.push" and .attributes."vcs.ref.name" == "refs/heads/'"$BASE_BRANCH"'")
' \
--timeout 600 || true)
# MANDATORY authoritative REST re-check on every wake-up.
STATE=$(gh api "repos/${REPO}/pulls/${pr_number}" \
--jq 'if .merged then "MERGED" elif .state == "closed" then "CLOSED" else "OPEN" end' \
2>/dev/null || echo "OPEN")
if [ "$STATE" = "MERGED" ]; then break; fi
if [ "$STATE" = "CLOSED" ]; then
echo "❌ PR #$pr_number was closed without merging"
exit 1
fi
EVENT=$(echo "$EVENT_JSON" | jq -r '.attributes."event.name" // ""')
case "$EVENT" in
github.check_suite.completed)
# CI failed — diagnose via merge-blocker-diagnosis.md, push fix.
;;
github.pr_review.submitted)
# Bot reviewers (Codex, claude-code-review) are addressable inline;
# humans require operator action. body.payload.author.type is "Bot" or "User".
# Codex submits inline-thread reviews as state="commented", not
# "changes_requested" — handle both via /catalyst-dev:review-comments,
# which addresses the code AND resolves threads via the GraphQL
# resolveReviewThread mutation.
AUTHOR_TYPE=$(echo "$EVENT_JSON" | jq -r '.body.payload.author.type // "User"')
if [ "$AUTHOR_TYPE" = "Bot" ]; then
/catalyst-dev:review-comments "$pr_number"
fi
;;
github.push)
gh pr update-branch "$pr_number" || true
;;
"")
# Timeout — gh api check above confirmed we're not merged.
# Diagnose blockers per the reference doc.
;;
esac
done
Why every wake-up runs gh api: if the orch-monitor daemon is down,
no GitHub webhook events flow into the log and wait-for blocks until
ti