| name | merge-pr |
| description | Safely merge PR with verification and Linear integration. **ALWAYS use when** the user says 'merge the PR', 'merge this', 'ship it', or wants to merge an approved pull request. Runs tests, checks CI, verifies approvals, squash merges, cleans up branches, and moves Linear ticket to Done. |
| disable-model-invocation | false |
| allowed-tools | Bash(linearis *), Bash(git *), Bash(gh *), Read |
| version | 1.0.0 |
Merge Pull Request
Safely merges a PR after comprehensive verification, with Linear integration and automated cleanup.
Prerequisites
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:
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
gh pr checkout $pr_number
base_branch=$(gh pr view $pr_number --json baseRefName -q .baseRefName)
git fetch origin $base_branch
if git log HEAD..origin/$base_branch --oneline | grep -q .; then
echo "Branch is behind $base_branch"
fi
If behind:
git rebase origin/$base_branch
if [ $? -ne 0 ]; then
echo "ā Rebase conflicts"
git rebase --abort
exit 1
fi
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.
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))
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)
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)
;;
github.pr_review.submitted)
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
;;
"")
;;
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
timeout (600 s). The gh api REST call after timeout is the safety net that keeps
merge confirmation correct even when the event stream has dropped. Same rule
applies on real event arrivals ā events are wake-up triggers, never the
source of truth. Use gh api (REST), never gh pr view --json (GraphQL).
The reference doc contains:
- The GraphQL query (merge state + CI checks + review threads + reviews in one call)
- Blocker type table (
CLEAN, BEHIND, DIRTY, BLOCKED, DRAFT, UNSTABLE, HAS_HOOKS,
UNKNOWN)
- How to decompose
BLOCKED into specific causes
- Resolution strategy for each blocker type:
| Blocker | Auto-resolution |
|---|
branch-behind (BEHIND) | gh api -X PUT /repos/{owner}/{repo}/pulls/{n}/update-branch (most repos disable GitHub's auto-update via allow_update_branch: false, so manual update is required), then continue polling |
conflicts (DIRTY) | Attempt gh pr checkout && git rebase origin/<base>; if unresolvable, exit non-success with specific files |
draft | gh pr ready |
ci-failing (UNSTABLE / failing checks) | Analyze failure logs, fix code, push, continue polling |
unresolved-threads | Run /review-comments (see review-thread-resolution.md); push fixes; continue polling for new comments |
changes-requested | Check if addressed; suggest re-request review |
review-required | Cannot fix ā exit non-success and report how many approvals needed and who to request |
hooks-pending (HAS_HOOKS) | Wait one cadence cycle and re-query |
unknown-blocker (UNKNOWN) | Query branch protection rules, report every requirement with status |
The outer loop has a MAX_ITER=20 cap to prevent runaway behaviour on a
stuck failure mode. Apply per-failure-type fix budgets inside each handler
(e.g., max 3 distinct fix attempts for the same CI failure mode). Exit
non-success only when a genuine human-gated blocker is identified
(unresolvable conflict, required reviewer, branch protection requirement that
cannot be satisfied).
When the loop confirms state == "MERGED", capture mergedAt and proceed to step 7.
Signal file write (when invoked from a worker context): if $SIGNAL_FILE is set (oneshot or
orchestrator worker), write pr.mergedAt, pr.ciStatus = "merged", and status = "done" to
the signal file as soon as state == MERGED is observed:
if [ -n "$SIGNAL_FILE" ] && [ -f "$SIGNAL_FILE" ]; then
PR_MERGED_AT=$(gh pr view "$PR_NUMBER" --json mergedAt --jq '.mergedAt')
jq --arg ts "$PR_MERGED_AT" \
'.pr.ciStatus = "merged" | .pr.mergedAt = $ts | .status = "done" | .updatedAt = $ts | .completedAt = $ts' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
fi
7. Extract ticket reference
branch=$(gh pr view $pr_number --json headRefName -q .headRefName)
title=$(gh pr view $pr_number --json title -q .title)
if [[ "$branch" =~ ($TEAM_KEY-[0-9]+) ]]; then
ticket="${BASH_REMATCH[1]}"
fi
if [[ -z "$ticket" ]] && [[ "$title" =~ ($TEAM_KEY-[0-9]+) ]]; then
ticket="${BASH_REMATCH[1]}"
fi
8. Show merge summary
About to merge:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
PR: #$pr_number - $title
From: $head_branch
To: $base_branch
Commits: $commit_count
Files: $file_count changed
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Merge: $mergeStateStatus (CLEAN)
Reviews: $review_status
CI: $ci_status
Tests: ā
Passed locally
Ticket: $ticket (will move to Done)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Merge strategy: Squash and merge
Proceed? [Y/n]:
9. Execute squash merge
gh pr merge $pr_number --squash --delete-branch
Always:
- Squash merge (combines all commits into one)
- Delete remote branch automatically
Capture merge commit SHA:
merge_sha=$(git rev-parse HEAD)
10. Update Linear ticket
If ticket found and not using --no-update:
"${CLAUDE_PLUGIN_ROOT}/scripts/linear-transition.sh" \
--ticket "$ticket_id" --transition done --config .catalyst/config.json
11. Delete local branch and update base
git checkout $base_branch
git pull origin $base_branch
git branch -d $head_branch
echo "ā
Deleted local branch: $head_branch"
Always delete local branch - no prompt (remote already deleted).
11a. Update primary worktree
If running in a git worktree, the primary checkout of main may be stale. Update it:
"${CLAUDE_PLUGIN_ROOT}/scripts/pull-primary-worktree.sh" --branch "$base_branch"
12. Extract post-merge tasks
Read PR description:
desc_file="thoughts/shared/prs/${pr_number}_description.md"
if [ -f "$desc_file" ]; then
tasks=$(sed -n '/## Post-Merge Tasks/,/^##/p' "$desc_file" | grep -E '^\- \[')
fi
If tasks exist:
š Post-merge tasks from PR description:
- [ ] Update documentation
- [ ] Monitor error rates in production
- [ ] Notify stakeholders
Save these tasks? [Y/n]:
If yes:
cat > "thoughts/shared/post_merge_tasks/${ticket}_tasks.md" <<EOF
# Post-Merge Tasks: $ticket
Merged: $(date)
PR: #$pr_number
$tasks
EOF
humanlayer thoughts sync
13. Detect Deployments and Report Success
After branch cleanup, check if the merge triggered any deployment workflows:
DEPLOY_RUNS=$(gh run list --branch "$base_branch" --limit 5 --json name,status,workflowName,url \
--jq '.[] | select(.status == "in_progress" or .status == "queued")' 2>/dev/null)
if [[ -n "$DEPLOY_RUNS" ]]; then
echo ""
echo "Active workflow runs detected after merge:"
gh run list --branch "$base_branch" --limit 5 --json workflowName,status,url \
--jq '.[] | select(.status == "in_progress" or .status == "queued") | " - \(.workflowName): \(.status) (\(.url))"'
echo ""
echo "Tip: Monitor deployment with:"
echo " /loop 3m gh run list --branch $base_branch --limit 3 --json workflowName,status,conclusion --jq '.[]'"
echo ""
else
echo ""
echo "No active deployment workflows detected."
fi
Display the standard success summary after this check:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā
PR #$pr_number merged successfully!
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Merge details:
Strategy: Squash and merge
Commit: $merge_sha
Base branch: $base_branch (updated)
Merged by: @$user
Cleanup:
Remote branch: $head_branch (deleted)
Local branch: $head_branch (deleted)
Linear:
Ticket: $ticket ā Done ā
Comment: Added with merge details
Post-merge tasks: $task_count saved to thoughts/
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Flags
--skip-tests - Skip local test execution
/catalyst-dev:merge-pr 123 --skip-tests
--no-update - Don't update Linear ticket
/catalyst-dev:merge-pr 123 --no-update
--keep-branch - Don't delete local branch
/catalyst-dev:merge-pr 123 --keep-branch
Combined:
/catalyst-dev:merge-pr 123 --skip-tests --no-update
Error Handling
For all errors, provide clear messages with the specific error, what went wrong, and how to fix it.
Never give up with a generic message ā always diagnose the specific cause and provide actionable
next steps.
Fail fast (stop execution):
- Rebase conflicts ā show conflicting files, instructions to resolve manually, then re-run
/catalyst-dev:merge-pr
- Test failures ā show failed tests, suggest fix or
--skip-tests
- PR not open/mergeable ā show current state
Diagnose and attempt to fix (step 6 blocker loop):
- CI checks failing ā analyze failure, attempt code fix, re-push, re-poll
- Unresolved threads ā run
/review-comments, resolve threads
- Branch behind ā rebase and push
- Draft PR ā mark as ready
- Changes requested ā check if addressed, suggest re-request review
- Infrastructure failures ā suggest re-run, provide log URL
Escalate with specifics (never generic):
- Review required ā tell user exactly how many approvals needed and who to request
- Unresolvable conflicts ā list specific files and what conflicts exist
- Unknown blockers ā query branch protection rules and list every requirement with its status
Never suggest:
- Force merge, admin override, or disabling branch protection
- Skipping required checks or reviews
- Any workaround that bypasses the protection rather than satisfying it
Warn but continue (graceful degradation):
- Linearis CLI not found ā warn, suggest install, merge proceeds
- Linear API error ā warn, merge proceeds
- Branch deletion error ā warn, merge already succeeded
Configuration
Uses .catalyst/config.json:
{
"catalyst": {
"project": {
"ticketPrefix": "PROJ"
},
"linear": {
"teamKey": "PROJ",
"stateMap": {
"done": "Done"
}
},
"pr": {
"defaultMergeStrategy": "squash",
"deleteRemoteBranch": true,
"deleteLocalBranch": true,
"updateLinearOnMerge": true,
"requireApproval": false,
"requireCI": false,
"testCommand": "make test"
}
}
}
State names are read from stateMap with sensible defaults. See .catalyst/config.json for all
keys.
Examples
/catalyst-dev:merge-pr 123
/catalyst-dev:merge-pr 123 --skip-tests
/catalyst-dev:merge-pr 123 --no-update
/catalyst-dev:merge-pr 123 --keep-branch
Safety Features
Never bypass branch protection:
- No
--admin, --force, or any flag that circumvents protection rules
- No disabling or modifying branch protection rules
- No suggesting the user disable protections
- Always satisfy requirements legitimately or escalate with specifics
Fail fast on:
- Merge conflicts (can't auto-resolve)
- Test failures (unless --skip-tests)
- Rebase conflicts
- PR not in mergeable state
Diagnose and fix automatically:
- CI failures ā analyze errors, fix code, push, re-poll
- Unresolved review threads ā run
/review-comments, resolve via GraphQL
- Branch behind ā rebase and push
- Draft PR ā mark as ready with
gh pr ready
Escalate with actionable specifics:
- Review required ā who to request, how many needed
- Changes requested ā what was asked, whether commits address it
- Unknown blockers ā full branch protection rule breakdown
Always automated:
- Rebase if behind (no conflicts)
- Squash merge
- Delete remote branch
- Delete local branch
- Update Linear to Done (if Linearis available)
- Pull latest base branch
Graceful degradation:
- If Linearis not installed, warn but continue
- Merge succeeds regardless of Linear integration
Remember:
- Never bypass branch protection ā diagnose and resolve blockers legitimately
- Always squash merge ā clean history
- Always delete branches ā no orphan branches
- Always run tests ā unless explicitly skipped
- Auto-rebase ā keep up-to-date with base
- Diagnose, don't give up ā identify specific blockers and fix or explain them
- Update Linear ā move ticket to Done automatically (if Linearis available)
- Graceful degradation ā work without Linearis if needed
- For Linearis CLI syntax, see the
linearis skill reference