Safely merge GitHub pull requests using comprehensive pre-merge validation. Use this skill when the user asks to merge a PR, complete a PR workflow, or check if a PR is ready to merge. Validates CI/CD status, reviews, and conflicts before merging with the appropriate strategy.
This skill enables safe and comprehensive pull request merging workflows using the gh CLI. It automates the complete pre-merge validation process, checks CI/CD status, verifies reviews, and executes merges with the appropriate merge strategy.
Use this skill when the user requests:
The skill accepts an optional PR number as argument:
/pr-merge 123 — operate on PR #123/pr-merge — detect PR from current branchWhen no PR number is given, detect the current branch's PR:
gh pr view --json number --jq '.number'
If no PR is associated with the current branch, ask the user for the PR number.
Before merging any PR, execute the complete validation workflow to ensure safety and quality.
gh pr view <number> --json title,body,state,isDraft,baseRefName,headRefName,author,number,mergeable,additions,deletions,changedFiles,reviewDecision
Verify:
state is OPENisDraft is falsemergeable is MERGEABLEBlock merge if the PR is draft, closed, or has merge conflicts.
gh pr checks <number>
Or for structured output:
gh pr checks <number> --json name,state,bucket --jq '.[] | select(.bucket != "pass")'
Block merge if:
SHA=$(gh pr view <number> --json headRefOid --jq '.headRefOid')
-H "Authorization: token $TOKEN" \
| python3 -c "
import json,sys
d=json.load(sys.stdin)
state=d['state']
print(f'Forgejo CI: {state} ({d[\"total_count\"]} checks)')
for s in (d.get('statuses') or []):
print(f' {s[\"context\"]}: {s[\"status\"]}')
if state == 'failure':
sys.exit(1)
"
Block merge if Forgejo CI is failure — report failing jobs and suggest /pr-fix.
If pending: warn user, suggest waiting. Do not proceed without explicit user override.
gh api repos/{owner}/{repo}/pulls/{number}/reviews --jq '[.[] | {user: .user.login, state: .state}]'
Or check reviewDecision from Step 1 JSON output — values are APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or empty.
Block merge if:
reviewDecision is CHANGES_REQUESTED or REVIEW_REQUIREDGet a summary of changed files and stats:
# File names and per-file stats (works for any PR size)
gh api repos/{owner}/{repo}/pulls/{number}/files --paginate \
--jq '.[] | "\(.status)\t+\(.additions) -\(.deletions)\t\(.filename)"'
IMPORTANT: Do NOT use gh pr diff --stat — that flag does not exist. The valid flags for gh pr diff are --color, --name-only, --patch, and --web only.
For small PRs, you can also use:
gh pr diff <number> --name-only
Note: gh pr diff and --name-only will fail with HTTP 406 if the diff exceeds 20,000 lines. Always fall back to the API endpoint above for large PRs.
Once all validations pass, execute the merge using gh pr merge.
Choose the appropriate merge method based on project conventions and commit history:
Squash Merge (-s):
Merge Commit (-m):
Rebase Merge (-r):
Default recommendation: Use -s (squash) unless project conventions dictate otherwise.
# Squash merge (default recommendation)
gh pr merge <number> -s -t "feat: description of change" -b "Details here" -d
# Merge commit
gh pr merge <number> -m -d
# Rebase merge
gh pr merge <number> -r -d
Flags reference:
-s / --squash — squash and merge-m / --merge — create merge commit-r / --rebase — rebase and merge-t / --subject — commit title (squash/merge)-b / --body — commit body text-d / --delete-branch — delete branch after merge--auto — enable auto-merge when requirements aren't yet metAlways ask user for confirmation before executing gh pr merge.
After successful merge, execute this sequence:
# Confirm merge
gh pr view <number> --json state,mergedAt --jq '"Merged at: \(.mergedAt)"'
Immediately after confirming merge, switch local checkout to the base branch:
MERGE_TARGET=$(gh pr view <number> --json baseRefName --jq '.baseRefName')
MERGE_TARGET="${MERGE_TARGET:-main}"
MERGED_BRANCH=$(gh pr view <number> --json headRefName --jq '.headRefName')
# Switch to base branch and pull latest
git checkout "$MERGE_TARGET" && git pull
# Delete local copy of the merged branch if it exists
if git show-ref --verify --quiet "refs/heads/$MERGED_BRANCH"; then
git branch -d "$MERGED_BRANCH" && echo "Deleted local branch: $MERGED_BRANCH"
fi
Do NOT ask for confirmation before deleting the just-merged branch — it was already merged and the user approved the merge. For all other branches, always confirm before deleting.
# Check if Forgejo is a push remote
# If yes, extract repo path from remote URL (NOT basename — local dir name may differ)
# Sync to Forgejo
Skip if no Forgejo push remote is configured.
After syncing, wait for CI to start then check the result:
TOKEN=$(cat ~/.config/forgejo/token)
SHA=$(git rev-parse HEAD)
sleep 60
for i in 1 2; do
RESULT=$(curl -s \
-H "Authorization: token $TOKEN")
STATE=$(echo "$RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['state'])" 2>/dev/null)
echo "Post-merge Forgejo CI: $STATE (attempt $i)"
[ "$STATE" = "pending" ] && sleep 30 || break
done
echo "$RESULT" | python3 -c "
import json,sys; d=json.load(sys.stdin)
print(f'Post-merge Forgejo CI: {d[\"state\"]} ({d[\"total_count\"]} checks)')
for s in (d.get('statuses') or []):
print(f' {s[\"context\"]}: {s[\"status\"]}')
"
If failure: flag for immediate attention (post-merge, cannot block — document as known issue and check /ops if this is a production deploy path).
# Fetch and prune stale remote tracking refs
git fetch --prune origin
MERGE_TARGET=$(gh pr view <number> --json baseRefName --jq '.baseRefName')
MERGE_TARGET="${MERGE_TARGET:-main}"
# Remote branches already merged into the target
MERGED_REMOTE=$(git branch -r --merged "origin/$MERGE_TARGET" | grep -v 'HEAD\|main\|release/' | sed 's|origin/||')
# Local branches with no corresponding remote (orphaned — remote was deleted)
ORPHANED_LOCAL=$(git branch | sed 's/^[* ]*//' | while read b; do
git show-ref --verify --quiet "refs/remotes/origin/$b" || echo "$b"
done | grep -v "^$MERGE_TARGET$\|^main$")
# Stale branches (no commits in 30+ days) — check both remote and local
THIRTY_DAYS_AGO=$(date -v-30d +%s 2>/dev/null || date -d '30 days ago' +%s)
for branch in $(git branch -r | grep -v 'HEAD\|main\|release/' | sed 's| origin/||'); do
LAST_EPOCH=$(git log -1 --format='%ct' "origin/$branch" 2>/dev/null)
LAST_DATE=$(git log -1 --format='%ci' "origin/$branch" 2>/dev/null)
if [[ -n "$LAST_EPOCH" && "$LAST_EPOCH" -lt "$THIRTY_DAYS_AGO" ]]; then
DAYS_OLD=$(( ($(date +%s) - LAST_EPOCH) / 86400 ))
echo "$branch: STALE ($DAYS_OLD days) — last commit $LAST_DATE"
else
echo "$branch: active — last commit $LAST_DATE"
fi
done
Report in structured format:
POST-MERGE BRANCH ANALYSIS
===========================
Current branch: v0.14.0-campaigns (switched automatically)
Just merged (deleted): feature/424-... (local + remote)
Orphaned local: feature/old-thing (remote gone, local remains)
Remote merged: feature/another (merged into base, safe to delete)
Stale (30+ days): feature/abandoned (45 days)
Active: feature/in-progress (2 days)
Release branches: release/v0.3.1 (3 open issues)
Suggest cleanup for orphaned local, remote-merged, and stale branches. Always confirm before deleting anything other than the just-merged branch.
Evaluate whether a tag/release is appropriate:
Tag if:
release/* branch into mainPropose tag:
# Get proposed version from CHANGELOG
head -20 CHANGELOG.md
echo "Create tag? git tag vX.Y.Z && git push --tags"
Create GitHub release:
gh release create vX.Y.Z \
--title "vX.Y.Z" \
--notes "$(sed -n '/## \[X.Y.Z\]/,/## \[/p' CHANGELOG.md | head -n -1)" \
--target main
If the merged PR was part of a version milestone:
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
MILESTONE="vX.Y.Z"
# Check remaining issues in milestone
gh api "repos/$REPO/milestones" \
--jq ".[] | select(.title==\"$MILESTONE\") | \"Open: \(.open_issues), Closed: \(.closed_issues), ID: \(.number)\""
If 0 open issues remain: propose closing the milestone
gh api "repos/$REPO/milestones/<id>" --method PATCH -f state=closed
If issues remain: report which are still open
Inspect what the merged PR changed to determine which docs need updating.
Detect changed files:
gh api repos/{owner}/{repo}/pulls/{number}/files --paginate \
--jq '.[].filename'
Decision tree:
A. New or changed skill detected (any file under skills/*/ or commands/*.md):
CLAUDE.md and locate the relevant pipeline section (e.g., "Review Workflow", "Media Pipeline")B. Skill arguments or workflow steps changed (SKILL.md content changed but no new skill):
README.md exists: note it as absent, do not create automaticallyC. No skills touched:
CHANGELOG promotion (evaluate independently of A/B/C above):
head -30 CHANGELOG.md 2>/dev/null || echo "No CHANGELOG"
[Unreleased] section is non-empty AND merging a release/* branch →
prompt user to run /release changelog to promote [Unreleased] → tagged version.
/release changelog Step 6 will also propagate the version bump to pyproject.toml,
package.json, Cargo.toml, and README badges, then propose a
chore: bump version to X.Y.Z commit. That commit must land on main before the git tag
is created — tag an unversioned state and the release artifacts will be wrong.
Only after the bump commit is in, verify before tagging:
git log --oneline main -3 # confirm bump commit is at HEAD
grep '^version' pyproject.toml 2>/dev/null || grep '"version"' package.json 2>/dev/null || grep '^version' setup.cfg 2>/dev/null || grep '^version' Cargo.toml 2>/dev/null
Then: git tag vX.Y.Z && git push --tagsCHANGELOG.md in repo → note it, suggest creating one, do not block the workflowPolicy violation check (see rules/claude-md-branching.md):
If the merged PR came from a
feature/*orfix/*branch and it modifiedCLAUDE.mdor rootREADME.md, flag this as a branching policy violation. Propose opening a new PR against main with a targeted revert of the CLAUDE.md/README change, and a follow-up note to apply it properly on the next release branch. Do not commit directly to main — always go through a PR to respect branch protection.
Evaluate whether user-facing or internal docs need updating based on what the PR changed.
Skip this step entirely if ANY of the following is true:
docs-registry.yaml exists anywhere in the repo (project has no docs pipeline)docs/* branch (already docs work)Trigger check:
# Check docs-registry.yaml exists
find . -name "docs-registry.yaml" -not -path "*/.git/*" | head -1
# Get code files changed by the PR
gh api repos/{owner}/{repo}/pulls/{number}/files --paginate \
--jq '[.[] | .filename | select(test("^(backend|app|src|frontend)/"))] | length'
If registry exists AND code file count > 0, proceed. Otherwise skip silently.
Steps:
/docs audit — read-only staleness + registry delta checkgh api repos/{owner}/{repo}/pulls/{number}/files --paginate \
--jq '[.[] | .filename]'
Match changed paths to file: entries in docs-registry.yaml.### Added entries since last tag (signals new features):
sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep '^- ' | head -10
Output format:
## Docsite Check
Mode: internal | Registry: 8 pages (5 published, 2 draft, 1 stale)
PR touched: backend/app/api/campaigns.py (+2 routes)
Affected registered docs: features/campaigns.md (published)
Suggestions:
→ /docs update features/campaigns.md (stale: code newer than docs)
→ /docs create feature-guide (new feature in [Unreleased]: "Campaign scheduling")
If nothing is affected:
## Docsite Check
No registered doc pages map to files changed in this PR. Skipping.
Rules:
/docs update or /docs create — always propose only/docs audit is slow, fall back to registry staleness check onlyAfter every merge, analyze the current release state and output guided next steps.
Gather state:
LAST_TAG=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null)
UNRELEASED=$(git log --no-merges --oneline "${LAST_TAG:-$(git rev-list --max-parents=0 HEAD)}..HEAD" | wc -l)
MILESTONE=$(cat .claude/current-milestone 2>/dev/null)
RELEASE_TAG_AT_HEAD=$(git tag --points-at HEAD | grep '^v' | head -1)
# Milestone progress (if milestone set)
if [[ -n "$MILESTONE" ]]; then
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null)
gh api "repos/$REPO/milestones" \
--jq ".[] | select(.title==\"$MILESTONE\") | \"Open: \(.open_issues), Closed: \(.closed_issues)\"" 2>/dev/null
fi
# Check [Unreleased] section
grep -c '^\- ' <(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md 2>/dev/null) 2>/dev/null || echo "0"
# Migration heads (if Alembic project)
if [[ -f alembic.ini ]]; then
HEADS=$(alembic heads 2>/dev/null | wc -l)
fi
Routing table (first match wins, top = highest priority):
| Priority | State | Guidance |
| -------- | ------------------------------------- | --------------------------------------------------------------------------------- |
| 1 | Multiple alembic heads detected | "Run alembic merge heads before next release" |
| 2 | Just tagged (HEAD has v* tag) | "Post-release: /release verify tags, close milestone, /release plan for next" |
| 3 | Milestone complete (0 open issues) | "Run /release verify commits → /release changelog → tag" |
| 4 | Milestone in progress | "Next: pick issue #N, run /implementation" |
| 5 | No milestone, >10 unreleased commits | "Consider /release plan to scope a version" |
| 6 | No milestone, 1-10 unreleased commits | "Continue working or /release verify commits when ready" |
Output format (appended to existing post-merge output):
## Release Status
- Last tag: v1.2.0 (15 commits since)
- Milestone: SPRINT-1 (3/5 closed, 2 remaining)
- Migration heads: 1 (clean)
- [Unreleased]: 8 entries
→ Next: pick issue #26, run /implementation
Always follow these safety rules:
--admin)When pre-merge validation fails, provide clear, actionable feedback:
CI/CD Failures:
gh run view <run-id> --log-failed/pr-fix <number> to triage and fix CI failures, review issues, and bot comments"Review Issues:
/review pr --pr <number> for companion-dispatched PR review"Architectural Concerns:
/review solve "concern description" for a second opinion"Merge Conflicts:
mergeable status from PR detailsgh api -X PUT repos/{owner}/{repo}/pulls/{number}/update-branchBranch Protection:
If the base branch has advanced:
gh api -X PUT repos/{owner}/{repo}/pulls/{number}/update-branch
If checks are still pending but everything else is ready:
gh pr merge <number> -s --auto
gh pr list --json number,title,reviewDecision,statusCheckRollup --jq '.[] | select(.reviewDecision == "APPROVED")'
User request: "Merge PR #42"
Execution sequence:
gh pr view 42 --json ... → Confirm open, not draft, mergeablegh pr checks 42 → All checks passingreviewDecision → APPROVEDgh api .../pulls/42/files --paginate → Review changed filesgh pr merge 42 -s -d → Execute mergeHandle common errors gracefully:
gh pr view <n> --json <fields> # Fetch PR metadata
gh pr checks <n> # CI/CD status
gh pr diff <n> --name-only # Changed filenames (small PRs only)
gh pr merge <n> -s|-m|-r # Merge with strategy
gh pr merge <n> --auto # Auto-merge when ready
gh api repos/O/R/pulls/N/files # Per-file diff stats (any size)
gh api repos/O/R/pulls/N/reviews # Review details
gh run view <id> --log-failed # Failed CI logs
Invalid flags to avoid:
gh pr diff --stat — does NOT existgh pr view --stat — does NOT existgh pr diff --stat does NOT exist — use gh api repos/{O}/{R}/pulls/{N}/files --paginate for file stats insteadgh pr diff and --name-only return HTTP 406 if the diff exceeds 20,000 lines — always fall back to the API files endpoint for large PRsgit remote -v, NOT from basename of the local directory — local dir names often differ from the repo path--ff-only merge in --main mode fails on non-linear history — user must rebase first; never force-mergereviewDecision: REVIEW_REQUIRED means no review has been submitted yet (distinct from CHANGES_REQUESTED) — both block mergegit fetch --prune is required before checking merged/stale branches or remote-gone branches won't appear correctlySearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer