
Ship Pr
- 1 installs
- 6 repo stars
- Updated August 3, 2026
- blackhole1/bh-skills
Ship PR is an agent skill that provides exact GitHub CLI and GraphQL commands to assess, discuss, and merge pull requests.
About
Ship PR is a procedural gh cookbook for developers who ship through GitHub pull requests and want copy-paste accurate commands instead of guessing CLI flags. It standardizes how you resolve the target PR on the current branch, derive owner and repo, and assess health via checks JSON (pass, fail, pending, skipping, cancel buckets) plus mergeability and merge state. When you need human review context, it points you at the GraphQL pull request reviewThreads query so each thread’s resolution, path, line, and comment databaseId are available for REST replies. The skill favors scripts/pr-state.sh for a consolidated JSON snapshot but documents the underlying gh pr checks and gh pr view fields when you need to debug edge cases. Use it when your agent is fixing review feedback, unblocking merges, or narrating PR status during ship week—not for writing feature code or designing CI from scratch.
- Resolves OWNER, REPO, and PR via gh repo view and gh pr view JSON queries
- One-shot PR snapshot via scripts/pr-state.sh with raw fallbacks for checks and merge state
- Documents mergeable, mergeStateStatus (CLEAN, BLOCKED, UNSTABLE, DIRTY, BEHIND), and reviewDecision fields
- GraphQL recipe for review threads with bodies, authors, resolution, and comment databaseId for replies
- Cookbook-style exact commands—no hand-wavy git steps
Ship Pr by the numbers
- 1 all-time installs (skills.sh)
- Ranked #527 of 733 Git & Pull Requests skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/blackhole1/bh-skills --skill ship-prAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | August 3, 2026 |
| Repository | blackhole1/bh-skills ↗ |
What it does
Run the exact gh CLI and GraphQL commands to inspect PR state, review threads, checks, and ship a merge-ready pull request.
Who is it for?
Best when you use gh on a single repo and want agent-driven PR triage, check interpretation, and review-thread replies.
Skip if: Skip if you're not on GitHub, greenfield repos without gh auth, or workflows that need full CI authoring instead of reading existing check status.
When should I use this skill?
When the agent needs to resolve, assess, or merge the current branch’s GitHub pull request using gh.
What you get
You have a deterministic command sequence for PR target resolution, state assessment, and review-thread access so the agent can drive merge readiness without improvising shell.
- PR state JSON snapshot
- Interpreted check and merge readiness
- Review thread data for targeted replies
By the numbers
- reviewThreads(first:100) GraphQL pagination cap in documented query
Files
ship-pr
Drive a pull request from "open" to "merged" without supervision: get CI green, resolve everything the reviewers raise, then merge. The hard part isn't any one step — it's the waiting and the judgment: knowing when to keep waiting, when a finding is real versus noise, and when the PR is truly safe to land.
Operating principles
- One PR at a time. Everything you do targets a single, explicit PR. Never
touch other PRs or branches. Resolve the target once at the start and reuse it.
- **Read and change PR code only in an isolated worktree, never the user's
checkout.* The user may be developing in this same repo while you babysit, so never touch their working tree, index, or branch. Do all code inspection, diagnosis, and fixes in a detached worktree synced to the PR head (see "Isolated worktree" below). This also keeps your triage* honest: you judge the real PR head, not whatever the user has checked out. Fix-and-push targets the PR head branch; never force-push or rewrite history under review.
- You may merge automatically once the terminal condition holds (see below).
This skill is meant to land PRs unattended. But never merge a PR that is CONFLICTING, has a required-review/required-check gap, or whose remaining failures you don't understand — surface those to the user instead.
- Only the reviewer bot resolves its own threads. You must not resolve review
conversations. The bot opened them to verify a concern; resolving on its behalf hides whether the concern was actually addressed and breaks the safety loop. You either fix and push (the bot re-reviews and resolves) or reply (the bot responds and resolves). See "Why you don't resolve threads" below.
- Be honest about uncertainty. If a CI failure isn't explained by the diff,
or a reviewer ask is ambiguous or large, stop and tell the user what you see rather than guessing.
Inputs
- Target PR: an explicit number if given, otherwise the PR for the current
branch. Resolve and pin it:
gh pr view --json number,url,headRefName,baseRefName,state -q '.number' # current branch's PR
gh repo view --json nameWithOwner -q .nameWithOwner # owner/repoIf there is no PR for the current branch and none was named, ask which PR (or offer to open one). If the PR is already MERGED/CLOSED, report and stop.
Isolated worktree (your view of the PR's code)
You touch the PR's files for exactly two reasons — to triage a finding (decide if it is real) and to fix one. Both must act on the PR's actual head code, not the user's working tree, which may be a different branch or stale. So the first time in a babysit session that you need to read or change PR code, create one isolated worktree and reuse it:
wt=$(scripts/pr-worktree.sh ensure <PR> [OWNER/REPO]) # prints the path- Lazy: don't create it for a clean PR that only needs wait-then-merge —
only when there's a finding to triage or a CI failure to diagnose.
- Always at the current head: call
ensureagain at the start of every
round; it hard-resets the worktree to the current PR head, so after you push a fix (or anyone else updates the PR) your next triage reads the new code, not a stale copy.
- Detached on purpose: git refuses to check out a branch already checked out
in another worktree, so if the user is sitting on the PR branch a normal checkout would fail. The worktree parks on the head commit (detached) and you push fixes with HEAD:<headRefName>, which never collides with their checkout.
- Push the fix back from the worktree:
- same-repo PR:
git -C "$wt" push origin HEAD:<headRefName> - fork PR: push to the fork instead
(git -C "$wt" push https://github.com/<headOwner>/<headRepo>.git HEAD:<headRefName>, needs write access to the fork). If you can't push to the fork, surface to the user rather than merging around the finding.
- Cleanup: after the merge,
scripts/pr-worktree.sh remove <PR> [OWNER/REPO].
ensure uses a real git worktree when you're inside a local clone of the target repo (cheap — shares the object store) and falls back to a throwaway clone when you're not. Assessing status and merging never need the worktree — they're pure gh calls.
The loop
Each cycle is assess → classify → act → wait. Repeat until merged.
1. Assess
Take one snapshot of the PR's health. The bundled helper does this in one shot:
scripts/pr-state.sh <PR>It prints a compact JSON with: each check's bucket, whether the non-bot checks all pass, the reviewer-bot check state, counts of review threads and unresolved review threads, mergeable, and mergeStateStatus. It also reports threads_fetched: if a transient GraphQL hiccup makes the thread fetch fail, the script reports threads_fetched:false, nulls the thread counts, and forces ready_to_merge:false — so a stale read can never green-light a merge with hidden unresolved threads. ready_to_merge:false with threads_fetched:false means retry the snapshot, not merge. Read references/gh-cookbook.md for the raw commands if you need detail or the script is unavailable.
2. Classify the current state
Decide which situation you're in (check in this order):
| Situation | Signal | Go to |
|---|---|---|
| Conflicts / blocked merge | mergeable=CONFLICTING, or a required gate that can't be satisfied automatically | Surface to user, stop |
| Real CI failure | a non-bot check is fail | Fix CI (§3a) |
| Review findings to handle | any unresolved review thread, or "changes requested" | Triage findings (§3b) |
| Reviewer bot still working | bot check pending / no review yet, other checks green | Wait (§4) |
| All green, nothing unresolved | all checks pass, 0 unresolved threads, mergeable=MERGEABLE | Merge (§5) |
A note on the reviewer bot: a passing bot check with zero actionable comments is the clean case. CodeRabbit states this explicitly in its summary ("No actionable comments were generated…" or "Actionable comments posted: N"); scripts/pr-comments.sh extracts that verdict for you into its header line, so you don't have to fetch and scan the summary comment by hand. See references/reviewer-bots.md for what the verdict means.
3. Act
3a. Real CI failure
Pull the failing job's log, find the cause, and decide:
- Caused by the diff (test/lint/type/build broke) → fix it in the worktree
(wt=$(scripts/pr-worktree.sh ensure <PR>), synced to the PR head). Run the project's own checks there first (discover them from CLAUDE.md, package.json/Makefile/CI config — e.g. lint, type-check, dead-code, tests), commit with a conventional message, and push the head back (git -C "$wt" push origin HEAD:<headRefName>). Pushing re-triggers CI and re-review, so you loop back to Assess.
- Flaky or infra (network, runner, unrelated) → re-run the job
(gh run rerun <run-id> --failed) once; if it still fails, surface to user.
- Not a code problem you can own (secrets, required approval) → surface.
3b. Triage review findings
Get the findings in triage-ready form — don't hand-fetch raw GraphQL/REST and parse it yourself (that burns tokens on HTML comments, base64 state blobs, and duplicated suggestion blocks the bots bury their point in):
scripts/pr-comments.sh <PR> # cleaned Markdown of every unresolved findingIt prints, per finding: severity/type tags, path:line, the cleaned finding text, one suggested-fix diff, and the reply-to id — plus a header tally (unresolved/resolved counts, by-severity, and the bot's "Actionable comments posted: N" / "No actionable comments" verdict). It strips the noise in the script so you read only signal. Add --all to include resolved threads, --full to keep every collapsible section, --json for structured output.
Then read the code each finding points at in the worktree (scripts/pr-worktree.sh ensure <PR>, synced to the PR head) — so you judge the PR's real current code, not the user's checkout. For each, make a judgment call:
- Real issue → fix it in the worktree. For a handful of small, clear
findings, just fix them. For many findings, or subtle/cross-cutting ones, use /workflows to fan out analysis and adversarially verify before changing code — it's worth the tokens to avoid thrashing. After fixing: run the project's checks in the worktree, commit, and push the head (git -C "$wt" push origin HEAD:<headRefName>). The bot re-reviews the new commit and resolves the thread itself.
- False positive / won't-fix → reply to that specific review comment
explaining why (concise, technical, points at the code). Use the reply-to id that pr-comments.sh printed for the finding and pipe the body in (never inline — an apostrophe or backtick in your reasoning breaks the shell):
printf '%s' "@coderabbitai This is intentional: <code-grounded reason>." \
| scripts/pr-reply.sh <PR> <reply-to id>It posts once, won't double-post on a transient EOF, and is a no-op if you already replied. Then wait for the bot to respond. Do not resolve the thread. CodeRabbit will either accept (and resolve) or push back; if it pushes back with a fair point, reconsider.
Batch your response: fix all the real ones in one commit/push where they're related, and post replies to the false positives, then go wait. One push + several replies is better than many tiny round-trips.
4. Wait (the part that needs patience)
You're waiting for an external event — CI finishing, or the bot posting/finishing a review. Don't burn cycles polling by hand.
Standalone (default): arm a persistent background monitor that emits only when something changes, then let it wake you:
Monitor(
description: "PR <N>: CI + reviewer-bot state changes",
persistent: true,
command: scripts/pr-watch.sh <N>
)pr-watch.sh polls every ~10s (override with PR_WATCH_INTERVAL) and prints a line only when something meaningful changes: check buckets, unresolved-thread count, review-comment count, head SHA, or merge state. It is debounced — it carries the last stable merge state through GitHub's post-push UNKNOWN/null recompute, treats a vanishing bot check as pending, and requires a state to hold for two polls before emitting — so reviewer-bot churn and one-poll flashes (including a transient ready=true) never wake you. You're notified on the real transition (CI done, review posted, bot replied/resolved, ready to merge) instead of on a timer. Pair it with a long fallback heartbeat (e.g. ScheduleWakeup ~1500s) in case an event is missed. When the terminal condition is reached, TaskStop the monitor.
Never `sleep N && <poll>`. Chaining a sleep before a command is blocked by the harness. To wait on a state transition, use the Monitor above; to wait on a command you started, run it in the background. A bare post-push merge=UNKNOWN or an empty bot bucket is expected recompute churn, not a stuck PR — the watcher already absorbs it, so don't hand-roll "real snapshot" confirmation reads.
Composed under /loop: if the user wrapped this in /loop, don't arm your own monitor — do a single assess→classify→act cycle and return. /loop re-invokes you on its own cadence. (/loop with no interval self-paces and is a good fit.)
5. Merge
Only when all of: every check pass, 0 unresolved review threads, mergeable=MERGEABLE, mergeStateStatus=CLEAN — i.e. pr-state.sh reports ready_to_merge:true (which already requires threads_fetched:true, so a degraded read can't green-light a merge).
Use the bundled helper — it re-confirms that gate, preserves the repo's DCO sign-off, retries a transient EOF, and verifies the result so a flaky merge call never double-merges or looks like a failure:
scripts/pr-merge.sh <N> # squash by default; --strategy merge|rebase, --subject/--body to overrideIt refuses (exit 3) if the PR isn't ready, is a no-op if already merged, and prints the merge commit. Match the repo's convention (most squash-merge repos show single-line type(scope): subject (#NNN) history; the default subject is <PR title> (#N)). Then clean up — scripts/pr-worktree.sh remove <PR> if you created one, and TaskStop any monitor you armed — and report: merge commit, what was fixed, what was replied to. Don't schedule further work — the goal is done.
Why you don't resolve threads
GitHub lets anyone with write access resolve a review thread (GraphQL resolveReviewThread). It's tempting to resolve threads to make the PR look clean. Don't. The reviewer bot tracks its own findings by whether it resolved them after seeing your fix or reply. If you resolve a thread the bot still considers open, you (a) suppress a concern that may be unaddressed and (b) can desync the bot, which may re-open or re-comment. Let the loop work: fix→push or reply→wait, and the bot resolves when satisfied. This skill never calls resolveReviewThread.
Reviewer bots beyond CodeRabbit
CodeRabbit is the primary case, but the logic generalizes: treat any unresolved review thread or "changes requested" as blocking, fix or reply, and let the reviewer (bot or human) resolve. Human reviewers won't auto-resolve on a timer — if a human requested changes, push your fix and then it's reasonable to leave a short reply and let the user know a human re-review is pending rather than waiting indefinitely. See references/reviewer-bots.md.
Bundled resources
All bundled scripts retry transient GitHub API failures (EOF/5xx) internally, so prefer them over hand-issued gh api — you won't have to babysit a flaky read.
scripts/pr-state.sh <PR> [OWNER/REPO]— one-shot JSON snapshot of CI +
review + merge state, incl. threads_fetched and ready_to_merge. Use it for every Assess. The repo arg is optional when you run inside the target repo.
scripts/pr-comments.sh <PR> [OWNER/REPO] [--all|--full|--json|--no-summary]—
fetch review threads + the reviewer-bot summary and print cleaned, triage-ready Markdown: per-finding severity/type tags, path:line, the finding text with HTML comments / base64 / duplicated suggestion blocks stripped, one fix diff, the reply-to id, and a header tally with the bot's actionable verdict. The moment a bot posts findings or unresolved>0, run this first — don't re-fetch raw gh api graphql or grep the summary by hand.
scripts/pr-reply.sh <PR> <reply-to id> [OWNER/REPO]— post an in-thread reply
(body from stdin or --body-file, never inline) to dispute a false positive; idempotent, retries transient EOF without double-posting.
scripts/pr-merge.sh <PR> [OWNER/REPO] [--subject|--body|--strategy]—
gated, DCO-preserving, idempotent, verify-after merge. Refuses unless ready.
scripts/pr-watch.sh <PR> [OWNER/REPO]— debounced change-emitting poll
loop for the Monitor tool (absorbs merge-recompute / bot-edit churn).
scripts/pr-worktree.sh ensure|remove|path <PR> [OWNER/REPO]— create / refresh
/ tear down an isolated detached worktree (or clone) synced to the PR head, so triage and fixes never touch the user's working tree.
scripts/gh-retry.sh—gh_retry <cmd…>wrapper the other scripts source to
retry transient GitHub API failures; rarely called directly.
references/gh-cookbook.md— exactgh/gh api/ GraphQL commands for
reading checks, reading and replying to review threads, re-running jobs, and merging.
references/reviewer-bots.md— how to read CodeRabbit's summary and comments,
what "resolved" means, and how to generalize to other reviewers.
gh cookbook for ship-pr
Exact commands for each operation. OWNER, REPO, and PR are placeholders; derive the first two with gh repo view --json owner,name and pin PR once.
Resolve the target
PR=$(gh pr view --json number -q .number) # current branch's PR
read OWNER REPO < <(gh repo view --json owner,name -q '.owner.login+" "+.name')
gh pr view "$PR" --json number,url,state,headRefName,baseRefName,isDraftAssess state
Prefer scripts/pr-state.sh "$PR" for a one-shot JSON snapshot. Raw pieces:
gh pr checks "$PR" --json name,bucket,state # bucket: pass|fail|pending|skipping|cancel
gh pr view "$PR" --json mergeable,mergeStateStatus,reviewDecision,statemergeable:MERGEABLE|CONFLICTING|UNKNOWN.mergeStateStatus:CLEAN(mergeable, checks done) |BLOCKED|UNSTABLE
(a non-required/ pending check) | DIRTY (conflicts) | BEHIND.
Read review threads (with bodies, authors, resolution, reply ids)
Prefer the bundled script — scripts/pr-comments.sh "$PR" fetches these threads and the bot summary, strips the noise (HTML comments, base64 state, duplicated suggestion blocks), and prints cleaned Markdown with severity tags, path:line, fix diffs, reply-to ids, and the actionable verdict. Reach for the raw commands below only when you need a field the script doesn't surface.
Review threads live in the GraphQL API. This returns each thread's resolution state plus every comment's databaseId (the REST id you reply to):
gh api graphql -F owner="$OWNER" -F repo="$REPO" -F pr="$PR" -f query='
query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$pr){
reviewThreads(first:100){nodes{
isResolved isOutdated path line
comments(first:50){nodes{ databaseId author{login} body }}
}}
}
}
}'The flat REST list of review (line) comments is handy too:
gh api "repos/$OWNER/$REPO/pulls/$PR/comments" \
--jq '.[] | {id, user: .user.login, path, line, body}'The reviewer bot's PR-level summary is an issue comment, not a review comment:
gh api "repos/$OWNER/$REPO/issues/$PR/comments" \
--jq '.[] | select(.user.login|test("coderabbit";"i")) | .body'Reply to a review comment (dispute a false positive)
Prefer `scripts/pr-reply.sh "$PR" "$COMMENT_ID"` with the body on stdin — it avoids the shell-quoting hazards of an inline body (an apostrophe/backtick in the reason breaks -f body=), is idempotent, and retries a transient EOF without double-posting. Raw form (note -F body=@- reads stdin; never inline a body with quotes):
printf '%s' "@coderabbitai This is intentional: <concise technical reason>." \
| gh api "repos/$OWNER/$REPO/pulls/$PR/comments/$COMMENT_ID/replies" -F body=@-COMMENT_ID = a databaseId from the GraphQL query above (or the reply-to id that scripts/pr-comments.sh prints).
Addressing CodeRabbit directly with @coderabbitai makes it respond and, if it agrees, resolve the thread. For a general (non-thread) note use gh pr comment "$PR" --body "...".
Do NOT resolve threads
Resolving is resolveReviewThread (GraphQL). This skill deliberately never calls it — the reviewer resolves its own threads after seeing your fix or reply. Listed here only so it's recognizable and explicitly avoided.
Fix → push from the isolated worktree (re-triggers CI + re-review)
Never edit the user's checkout. Work in the worktree synced to the PR head:
wt=$(scripts/pr-worktree.sh ensure "$PR") # detached worktree at the PR head
# edit files under "$wt", run the project's own checks THERE, then:
git -C "$wt" commit -s -am "fix(scope): address review — <what>"
git -C "$wt" push origin HEAD:<headRefName> # same-repo PR; never --force
# fork PR — push to the fork instead (needs write access to it):
# git -C "$wt" push https://github.com/<headOwner>/<headRepo>.git HEAD:<headRefName>
scripts/pr-worktree.sh remove "$PR" # after the PR is mergedResolve <headRefName> and whether it's a fork:
gh pr view "$PR" --json headRefName,isCrossRepository,headRepositoryOwner,headRepositoryRe-run a failing job (suspected flake)
gh run list --branch "$(gh pr view "$PR" --json headRefName -q .headRefName)" --limit 5
gh run rerun <run-id> --failedMerge
Prefer `scripts/pr-merge.sh "$PR"` — it re-confirms the ready gate, preserves the DCO sign-off, retries a transient EOF, runs remote-only (-R, so gh never fast-forwards your local checkout), and verifies the result (idempotent: a no-op if already merged, never a double-merge). Raw form:
gh pr merge "$PR" --squash --delete-branch \
--subject "<PR title> (#$PR)" \
--body "<short rationale; keep a Signed-off-by line if the repo uses DCO>"Use --merge or --rebase instead of --squash only if that matches the repo's history convention. Confirm the strategy is enabled if a merge call is rejected (gh repo view --json squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed). Verify with gh pr view "$PR" --json state,mergedAt,mergeCommit (not merged — that field name varies by gh version).
Reviewer bots
How to read automated reviewers so you triage correctly. CodeRabbit is the primary case; the last section generalizes.
CodeRabbit
Status check. A check named CodeRabbit flips pending → pass when a review round finishes. pending with everything else green means keep waiting, not ready.
Summary comment. CodeRabbit posts (and updates) one PR-level issue comment. scripts/pr-comments.sh reads it for you and surfaces the verdict in its header line, so you normally don't open it by hand. The verdict is one of:
No actionable comments were generated in the recent review.→ nothing to fix;
the review is clear.
Actionable comments posted: N→ there are N line-level review comments to
triage. The raw summary also lists files reviewed and files skipped by path filters (e.g. *.snap), which explains why a changed file may not have been commented on — read it directly (or with --full) on the rare occasion you need that.
Actionable findings arrive as line-level review comments (the REST pulls/{pr}/comments list / GraphQL review threads). Each often includes a committable suggestion and a collapsible AI prompt. Treat each as a thread.
Resolution. CodeRabbit resolves a thread itself once it sees the concern addressed — either by a new commit that fixes it, or by a reply it accepts. So:
- Real issue → fix, push. The incremental re-review picks up the new commit and
resolves the thread.
- False positive → reply in-thread starting with
@coderabbitaiand a concise,
code-grounded reason. It will respond; if it agrees it resolves, if not it pushes back (reconsider — it's sometimes right). Never resolve on its behalf: an unresolved thread is the only honest signal that a concern is still open.
Re-review cadence. Pushing a commit triggers an incremental review (seconds to a few minutes). You can also comment @coderabbitai review to force one, or @coderabbitai resolve to ask it to resolve threads it considers done — but prefer letting it resolve naturally.
Profiles. A CHILL profile raises fewer nits than ASSERTIVE; don't assume silence means it didn't look — confirm via the summary comment.
Generalizing to other reviewers
The control loop is reviewer-agnostic: any unresolved review thread or a `reviewDecision` of `CHANGES_REQUESTED` is blocking. Fix or reply, then let the reviewer resolve.
- Other bots (Sourcery, Qodo/Codium, Greptile, Ellipsis, …) behave like
CodeRabbit: a status check, line comments, self-resolution. Add their check name to the botre pattern in scripts/pr-state.sh so they're classed as review rather than CI.
- Human reviewers don't resolve on a timer. After you push a fix, leave a
short reply noting what changed, then don't block forever waiting — tell the user a human re-review is pending and let them decide. Auto-merging past a human's requested changes is not your call.
Don't confuse review checks with CI
scripts/pr-state.sh splits checks into review_bot_checks and CI. The merge gate needs both green, but they fail for different reasons: a red CI job is a code problem you fix; a red/pending bot check is a review you respond to. Keep them distinct when you classify.
#!/usr/bin/env bash
# gh_retry — run a gh / gh api / git command, retrying ONLY transient failures
# (GitHub's intermittent "EOF", 5xx, timeouts, connection resets). The GitHub API
# drops connections often enough that every ship-pr session hit at least one
# "Post https://api.github.com/graphql: EOF"; without this the agent hand-rolls
# `until CMD; do sleep N; done` loops, and a transient read can silently degrade a
# snapshot. Centralizing the retry here means the bundled scripts are resilient
# and the agent never has to babysit a flaky read.
#
# Use two ways:
# - source it, then call the function: source gh-retry.sh; out=$(gh_retry gh api ...)
# - run it as a command wrapper: out=$(gh-retry.sh gh api ...)
#
# Tunables (env): GH_RETRY_TRIES (default 3), GH_RETRY_DELAY (base seconds, default 2;
# backoff is delay*attempt). A NON-transient failure returns immediately (no point
# retrying a 404 or a bad query). `gh pr checks` legitimately exits non-zero while
# printing valid JSON, so success is judged by "non-empty output AND no transient
# error on stderr", not by exit code alone.
#
# Requires: bash, the command being wrapped (gh/git).
gh_retry() {
local tries="${GH_RETRY_TRIES:-3}" delay="${GH_RETRY_DELAY:-2}" i out rc err
local transient='EOF|timed? ?out|50[234]|connection reset|connection refused|temporarily unavailable|i/o timeout|TLS handshake'
err="$(mktemp 2>/dev/null || echo "/tmp/gh_retry.$$")"
i=1
while [ "$i" -le "$tries" ]; do
out="$("$@" 2>"$err")"; rc=$?
# Non-empty output IS success — even with a non-zero exit (gh pr checks exits
# non-zero while printing valid JSON) and even if stderr carries benign noise
# that happens to match a transient word. A dropped connection yields EMPTY
# output, which is what we actually retry on.
if [ -n "$out" ]; then
rm -f "$err"; printf '%s' "$out"; return 0
fi
if grep -qiE "$transient" "$err" 2>/dev/null; then
sleep "$((delay * i))"
i="$((i + 1))"
continue
fi
break # empty output with a non-transient (or no) error — retrying won't help
done
rm -f "$err"
printf '%s' "$out"
return "${rc:-1}"
}
# When executed directly (not sourced), act as a command wrapper.
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
gh_retry "$@"
fi
#!/usr/bin/env bash
# Fetch a PR's review threads (and the reviewer-bot summary), strip the noise that
# bots like CodeRabbit bury their findings in, and print clean Markdown — so the
# agent reads triage-ready content instead of spending tokens parsing raw GraphQL
# JSON full of HTML comments, base64 state blobs, and duplicated <details> blocks.
#
# What it removes from each *bot* comment body:
# - every HTML comment <!-- ... --> (fingerprints, cr-comment ids, the giant
# base64 "internal state" blob), even an unterminated/truncated trailing one
# - the duplicated / verbose collapsible sections (📝 Committable suggestion,
# 🤖 Prompt for AI Agents, 🧰 Tools / 🪛 linter dumps, Finishing Touches, tips),
# including everything nested inside them
# What it keeps: the finding prose, the path:line, a single suggested-fix diff,
# "Also applies to" lines, and the reply-to id you dispute false positives against.
# Human comments keep their full text (only the bot's own-line HTML-comment chrome
# is stripped; an inline `<!--` in prose or a code span is left verbatim) — the
# noise-dropping and <details> rewriting are reviewer-bot chrome, applied only to
# bot authors. Content inside fenced ``` / ~~~ code blocks is preserved verbatim
# (a reviewer quoting HTML or a diff is never mangled), using CommonMark-style
# fence matching (closing fence same char, length >= opener; unclosed fences
# protect nothing). What it computes for you (so the agent doesn't): per-finding
# severity / type / quick-win tags and a header tally (unresolved, resolved,
# by-severity counts, and the "Actionable comments posted: N" verdict).
#
# Usage: pr-comments.sh [PR_NUMBER] [OWNER/REPO] [flags]
# --json emit structured JSON instead of Markdown
# --all include resolved threads (default: unresolved only, + a count)
# --no-summary skip the reviewer-bot PR-level summary block
# --full keep every <details> section (only strips HTML comments)
#
# PR_NUMBER defaults to the current branch's PR (must run inside the repo)
# OWNER/REPO defaults to $GH_REPO, else the repo of the current directory
#
# Testing seam: set PRC_THREADS_FILE / PRC_SUMMARY_FILE to read saved JSON
# instead of calling gh (lets the cleaning be unit-tested offline).
#
# Requires: gh (authenticated), jq.
set -uo pipefail
dir="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=/dev/null
. "$dir/gh-retry.sh" # gh_retry: retry transient GitHub API EOF/5xx
pr=""; repo="${GH_REPO:-}"; want_json=0; want_all=0; no_summary=0; full=false
for a in "$@"; do
case "$a" in
--json) want_json=1 ;;
--all) want_all=1 ;;
--no-summary) no_summary=1 ;;
--full) full=true ;;
-*) echo "unknown flag: $a" >&2; exit 2 ;;
*/*) repo="$a" ;;
*) [ -z "$pr" ] && pr="$a" || repo="$a" ;;
esac
done
R=()
[ -n "$repo" ] && R=(-R "$repo")
if [ -z "$pr" ]; then
pr="$(gh pr view "${R[@]+"${R[@]}"}" --json number -q .number 2>/dev/null)" || true
fi
if [ -z "$pr" ]; then
echo '{"error":"no PR (pass a PR number, and OWNER/REPO unless run inside the repo)"}'
exit 0
fi
if [ -n "$repo" ]; then
owner="${repo%%/*}"; name="${repo##*/}"
else
ownername="$(gh repo view --json owner,name -q '.owner.login + " " + .name' 2>/dev/null)" || true
owner="${ownername%% *}"; name="${ownername##* }"
fi
# --- Fetch review threads (or read a saved fixture) ---
if [ -n "${PRC_THREADS_FILE:-}" ]; then
threads="$(cat "$PRC_THREADS_FILE")"
else
threads="$(gh_retry gh api graphql -F owner="$owner" -F repo="$name" -F pr="$pr" -f query='
query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$pr){
reviewThreads(first:100){nodes{
isResolved isOutdated path line startLine
comments(first:50){nodes{ databaseId author{login} createdAt body }}
}}
}
}
}')" || true
fi
[ -z "$threads" ] && threads='{}'
# Be honest about a failed fetch: a GraphQL error or a null payload must not be
# reported as "0 unresolved" — for a babysit tool that could green-light a merge
# on a stale read. A PR with genuinely zero threads still has a non-null payload.
if printf '%s' "$threads" | jq -e '.errors or (.data == null)' >/dev/null 2>&1; then
echo '{"error":"failed to fetch review threads (GraphQL error, auth, or transient API hiccup) — retry"}'
exit 0
fi
# --- Fetch the reviewer-bot PR-level summary (one issue comment it keeps updated) ---
summary=""
if [ "$no_summary" -eq 0 ]; then
if [ -n "${PRC_SUMMARY_FILE:-}" ]; then
summary="$(cat "$PRC_SUMMARY_FILE")"
else
summary="$(gh api "repos/$owner/$name/issues/$pr/comments" --paginate 2>/dev/null \
| jq -s 'add // []' 2>/dev/null)" || true
fi
fi
[ -z "$summary" ] && summary='[]'
# ---------------------------------------------------------------------------
# The cleaner + renderer. All text processing happens here in jq so the agent
# never sees the raw noise. Long, but it runs in the script, not in context.
# ---------------------------------------------------------------------------
jq_prog='
# Collapsible sections of a *bot* body we drop wholesale (with everything nested
# inside them): pure duplication or verbose dumps. Applied only to bot authors.
# Matched against the lowercased <summary> label and anchored at the *start* of
# the label (CodeRabbit leads each with its marker), so a legit section whose
# title merely contains a phrase like "how to simplify code" is never dropped.
def NOISE:
"^\\s*(🪛|🧰)" # 🧰 Tools / 🪛 <linter> dumps (any sub-name)
+ "|^\\s*(📝|🤖|🧩|🧠|🚥|✨)?\\s*(committable suggestion|prompt for ai agents|tools|finishing touch(es)?|simplify code|pre-merge|analysis chain|outside diff range)\\b"
+ "|^\\s*(💡\\s*)?tips\\s*$";
# A login is a reviewer bot iff it is GitHub-App-suffixed (slug[bot]) or exactly
# one of the known reviewer slugs (CodeRabbit posts review comments as the bare
# "coderabbitai"). Anchored so a human login that merely *contains* a keyword
# (e.g. "cursorfan", "precursor") is never misread as a bot.
def is_bot($login):
(($login // "") | ascii_downcase) as $l
| ($l | test("\\[bot\\]$"))
or ($l | test("^(coderabbitai|coderabbit|sourcery|sourceryai|codium|codiumai|qodo|greptile|ellipsis|bugbot|cursor)$"));
# Strip down a body to triage-ready Markdown via a two-pass, fence- and
# nesting-aware state machine (no flat sentinels, so unclosed fences and orphan
# tags can never swallow real signal).
# $is_bot : when true, drop NOISE <details> subtrees and unwrap other
# <summary> labels to **bold**; when false (human), leave all
# <details>/<summary> markup untouched. Own-line HTML comments are
# stripped for everyone; inline "<!--" (prose or code span) is kept.
def clean($is_bot):
(split("\n")) as $lines
| ($lines | length) as $n
# --- Pass 1: matched fence pairs (CommonMark-ish: opener of N backticks/tildes
# closes only on a bare marker of the same char and length >= N; an
# unclosed opener protects nothing). $prot[i] => line i is inside a fence.
| (reduce range(0; $n) as $i ({open:null, prot:{}};
($lines[$i] | [capture("^ {0,3}(?<f>`{3,}|~{3,})(?<rest>.*)$")] | .[0]) as $m
| if $m == null then .
elif (.open == null) then
# opener — but a backtick fence info string may not contain backticks
(if (($m.f[0:1]) == "`" and ($m.rest | test("`"))) then .
else .open = {i:$i, f:$m.f} end)
else
(if (($m.f[0:1]) == (.open.f[0:1]))
and (($m.f|length) >= (.open.f|length))
and (($m.rest | test("[^[:space:]]")) | not)
then .prot = (reduce range(.open.i; $i+1) as $j (.prot; .[$j|tostring]=true)) | .open=null
else . end)
end)
| .prot) as $prot
# --- Pass 2: emit cleaned lines. dropdepth/depth track <details> nesting so a
# NOISE section drops its whole subtree; incmt eats multi-line HTML comments.
| reduce range(0; $n) as $i ({out:[], incmt:false, dropdepth:-1, depth:0};
$lines[$i] as $line
# Eating a multi-line HTML comment takes precedence over fence protection:
# keep consuming until "-->", then preserve any real text after it.
| if .incmt then
(if ($line | test("-->"))
then (.incmt=false)
| (($line | sub("^.*?-->"; "")) as $tail
| if (.dropdepth < 0 and ($tail | test("[^[:space:]]"))) then (.out += [$tail]) else . end)
else . end)
elif ($prot[$i|tostring] == true) then
(if .dropdepth < 0 then .out += [$line] else . end)
else
# Strip an HTML comment only when it OWNS the line (after optional
# whitespace) — that is how reviewer bots emit their state/fingerprint
# chrome. An inline "<!--" in prose, or a `<!--` code span, is the
# author text and is left verbatim (so it never eats real signal).
( ($line | test("^\\s*<!--")) as $cmt
| (if $cmt then ($line | gsub("<!--.*?-->"; "")) else $line end) as $l1
| (if ($cmt and ($l1 | test("<!--"))) then (.incmt=true) else . end)
| (if $cmt then ($l1 | gsub("<!--.*$"; "")) else $line end) as $l2
# A <summary> opens a labelled section at the current depth. Match it
# whether on its own line or combined as "<details><summary>…</summary>".
| ($line | test("^\\s*(<details[^>]*>)?<summary>.*</summary>\\s*$")) as $is_summary
| ($line | test("^\\s*<details[^>]*>\\s*$")) as $is_open
| ($line | test("^\\s*</details>\\s*$")) as $is_close
| if ($is_bot and $is_summary) then
(if ($line | test("^\\s*<details")) then (.depth += 1) else . end)
| (($line | [capture("<summary>\\s*(?<s>.*?)\\s*</summary>")] | .[0].s
| gsub("[*`]"; "") | gsub("^\\s+|\\s+$"; "")) as $lbl
| if (.dropdepth < 0 and ($lbl | ascii_downcase | test(NOISE))) then (.dropdepth=.depth)
elif (.dropdepth < 0 and ($lbl != "")) then (.out += ["", "**\($lbl)**", ""])
else . end)
elif ($is_bot and $is_open) then (.depth += 1)
elif ($is_bot and $is_close) then
(.depth = ([.depth-1, 0] | max))
| (if (.dropdepth >= 0 and .depth < .dropdepth) then (.dropdepth=-1) else . end)
else
(if .dropdepth < 0 then (.out += [$l2]) else . end)
end)
end)
| .out | join("\n")
| gsub("[ \t]+\n"; "\n") | gsub("\n{3,}"; "\n\n") | gsub("^\\s+|\\s+$"; "");
# HTML-comment strip used only for *metadata* extraction (tag header / title) so a
# leading "<!-- auto-generated by CodeRabbit -->" line cannot hide the tag header.
def strip_comments: gsub("<!--(.*?-->|.*)"; ""; "m");
# Is this line the bot tag header, e.g. "_⚠️ Potential issue_ | _🟠 Major_"? Only
# underscore-delimited tokens joined by "|", each carrying a symbol/emoji. The
# strictness (no prose between tokens) keeps a human line like "_note_ and _ok_ 🔧"
# from being mistaken for a header and stripped. Only ever applied to bot authors.
def is_tag_header($l):
($l | test("^\\s*_[^_|]+_(\\s*\\|\\s*_[^_|]+_)*\\s*$")) and ($l | test("\\p{So}"));
def header_tags:
(split("\n") | map(select(test("[^[:space:]]"))) | .[0] // "") as $first
| if is_tag_header($first)
then [ $first | scan("_[^_]+_") | gsub("^_|_$";"") | gsub("^\\s+|\\s+$";"") ]
else [] end;
def sev_text($t): # severity from structured tag text (emoji or word)
($t | ascii_downcase) as $x
| if ($x|test("critical|🔴")) then "critical"
elif ($x|test("major|🟠")) then "major"
elif ($x|test("minor|🟡|🔵")) then "minor"
elif ($x|test("nitpick|🧹")) then "nitpick"
else "other" end;
def sev_of($tags): sev_text($tags | join(" "));
# Fallback for bots that lead with a markdown heading ("## 🔴 Logic Error")
# instead of an italic tag line: classify by the severity emoji on the first
# non-empty line only (emoji, not words, to avoid prose false positives).
def sev_first:
(split("\n") | map(select(test("[^[:space:]]"))) | .[0] // "") as $l
| if ($l|test("🔴")) then "critical"
elif ($l|test("🟠")) then "major"
elif ($l|test("🟡|🔵")) then "minor"
elif ($l|test("🧹")) then "nitpick"
else "other" end;
def sev_rank($s):
{critical:0, major:1, minor:2, nitpick:3, other:4}[$s] // 5;
def title_of: # first **bold** line, else first line; sans markup
(split("\n") | map(select(test("[^[:space:]]")))) as $lines
| (($lines | map(select(test("\\*\\*"))) | .[0]) // ($lines[0] // ""))
| gsub("<[^>]+>";"") | gsub("\\*\\*";"") | gsub("^#+\\s*";"") | gsub("^\\s+|\\s+$";"");
def strip_header_line: # drop the "_…_ | _…_" line from displayed prose
split("\n")
| if (length>0 and is_tag_header(.[0]))
then .[1:] else . end
| join("\n") | gsub("^\\s+|\\s+$";"");
($threads.data.repository.pullRequest.reviewThreads.nodes // []) as $nodes
| [ $nodes[]
| (.comments.nodes // []) as $cs
| ($cs[0] // {}) as $root
| ($root.body // "") as $rb
| is_bot($root.author.login) as $root_is_bot
| ($rb | strip_comments) as $rb_meta
| ($rb | clean($root_is_bot)) as $root_clean
| (if $root_is_bot then ($rb_meta | header_tags) else [] end) as $tags
| {
resolved: .isResolved,
outdated: .isOutdated,
path: .path,
line: (.line // .startLine),
reply_to: $root.databaseId,
root_author: ($root.author.login // "?"),
is_bot: $root_is_bot,
tags: $tags,
severity: (
if ($tags|length) > 0 then sev_of($tags)
elif $root_is_bot then ($rb_meta | sev_first)
else "other" end),
quick_win: ($tags | any(ascii_downcase | test("quick win"))),
title: ($root_clean | title_of),
comments: [ $cs[]
| is_bot(.author.login) as $cbot
| {
author: (.author.login // "?"),
body: ( (.body // "")
| clean($cbot)
| (if $cbot then strip_header_line else . end) )
} ]
}
] as $all
| ($all | map(select(.resolved | not))) as $open
| ($all | map(select(.resolved))) as $done
| {
pr: $pr, repo: $repo,
threads_total: ($all|length),
unresolved: ($open|length),
resolved: ($done|length),
by_severity: ( ["critical","major","minor","nitpick","other"]
| map(. as $s | {key:$s, value: ($open | map(select(.severity==$s)) | length)})
| map(select(.value>0)) | from_entries ),
findings: ($open | sort_by(sev_rank(.severity))),
resolved_findings: $done
}
'
data="$(jq -n \
--argjson threads "$threads" \
--arg repo "$owner/$name" \
--argjson pr "$pr" \
--argjson full "$full" \
"$jq_prog" 2>/dev/null)" || true
if [ -z "$data" ]; then
echo '{"error":"failed to parse review threads"}'
exit 0
fi
# --- Reviewer-bot summary verdict (actionable count) extracted in-script ---
verdict="$(printf '%s' "$summary" | jq -r '
(map(select(((.user.login // "") | ascii_downcase) | (test("\\[bot\\]$") or test("coderabbit|sourcery|qodo|greptile|ellipsis"))))) as $b
| ($b | map(.body) | reverse) as $bodies
| ( [ $bodies[] | capture("Actionable comments posted:\\s*(?<n>[0-9]+)";"i").n ] | .[0] ) as $n
| ( [ $bodies[] | select(test("No actionable comments were generated";"i")) ] | length ) as $none
| if $n != null then "Actionable comments posted: \($n)"
elif $none > 0 then "No actionable comments were generated"
else "" end' 2>/dev/null)" || true
if [ "$want_json" -eq 1 ]; then
printf '%s' "$data" | jq --arg verdict "$verdict" '. + {actionable_verdict: $verdict}'
exit 0
fi
# --- Render Markdown ---
printf '%s' "$data" | jq -r --arg verdict "$verdict" --argjson all "$want_all" '
def sev_icon($s): {critical:"🔴",major:"🟠",minor:"🟡",nitpick:"🧹",other:"•"}[$s] // "•";
"# Review comments — PR #\(.pr) (\(.repo))",
"",
( [ "\(.unresolved) unresolved",
"\(.resolved) resolved"
] + (if $verdict != "" then [$verdict] else [] end)
| join(" · ") ),
( if (.by_severity | length) > 0
then "severity: " + ([ .by_severity | to_entries[] | "\(.key) \(.value)" ] | join(" · "))
else empty end ),
"",
( if (.findings | length) == 0
then "_No unresolved review threads._"
else ( .findings | to_entries[] |
.key as $i | .value as $f |
"## \(sev_icon($f.severity)) \($i+1). \($f.path // "?"):\($f.line // "?")"
+ (if $f.quick_win then " ⚡" else "" end),
( [ ($f.tags | if length>0 then join(" · ") else empty end),
(if $f.is_bot then empty else "by @\($f.root_author)" end) ]
| map(select(. != null)) | if length>0 then "_" + join(" · ") + "_" else empty end ),
"",
( $f.comments | to_entries[] |
if .key == 0 then .value.body
else "\n> **@\(.value.author):** " + (.value.body | gsub("\n";"\n> ")) end ),
"",
"↳ reply-to id: `\($f.reply_to)`",
"",
"---"
) end ),
( if $all and (.resolved_findings | length) > 0
then "",
"<details><summary>\(.resolved_findings|length) resolved</summary>",
( .resolved_findings[] | "\n- \(.path // "?"):\(.line // "?") — \(.title)" ),
"</details>"
else empty end )
'
#!/usr/bin/env bash
# Land a PR safely and idempotently. This bundles the merge dance every ship-pr
# session hand-rolled: re-confirm the terminal gate, preserve the DCO sign-off,
# merge through the transient-retry wrapper, and verify the result so a flaky
# "EOF" or a local-checkout "Not possible to fast-forward" never looks like a
# failure (or causes a double-merge).
#
# Usage: pr-merge.sh <PR> [OWNER/REPO] [flags]
# --subject S squash commit subject (default: "<PR title> (#<PR>)")
# --body B squash commit body (a DCO Signed-off-by from the PR's
# commits is appended automatically if missing)
# --strategy S squash (default) | merge | rebase
# --no-delete-branch keep the head branch
# --force skip the readiness gate (use ONLY when you have already
# confirmed state out-of-band; normally never pass this)
#
# Exit codes: 0 merged (or already merged); 3 refused (not ready); 1 error.
# Safety: REFUSES unless pr-state.sh reports ready_to_merge AND threads_fetched
# (a degraded snapshot can never green-light a merge). It runs against the remote
# (-R) so gh never tries to fast-forward your local checkout.
#
# Requires: gh (authenticated), jq, sibling pr-state.sh + gh-retry.sh.
set -uo pipefail
dir="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=/dev/null
. "$dir/gh-retry.sh"
pr=""; repo="${GH_REPO:-}"; subject=""; body=""; strategy="squash"
delete_branch=1; force=0
while [ $# -gt 0 ]; do
case "$1" in
--subject) [ $# -ge 2 ] || { echo "missing value for $1" >&2; exit 2; }; subject="$2"; shift 2 ;;
--body) [ $# -ge 2 ] || { echo "missing value for $1" >&2; exit 2; }; body="$2"; shift 2 ;;
--strategy) [ $# -ge 2 ] || { echo "missing value for $1" >&2; exit 2; }; strategy="$2"; shift 2 ;;
--no-delete-branch) delete_branch=0; shift ;;
--force) force=1; shift ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*/*) repo="$1"; shift ;;
*) [ -z "$pr" ] && pr="$1" || repo="$1"; shift ;;
esac
done
R=(); [ -n "$repo" ] && R=(-R "$repo")
if [ -z "$pr" ]; then
pr="$(gh pr view "${R[@]+"${R[@]}"}" --json number -q .number 2>/dev/null)" || true
fi
if [ -z "$pr" ]; then
echo "pr-merge: no PR (pass a number, and OWNER/REPO unless run inside the repo)" >&2
exit 1
fi
if [ -n "$repo" ]; then
owner="${repo%%/*}"; name="${repo##*/}"
else
ownername="$(gh repo view --json owner,name -q '.owner.login + " " + .name' 2>/dev/null)" || true
owner="${ownername%% *}"; name="${ownername##* }"
fi
slug="$owner/$name"
# Idempotent verify helper: prints MERGED commit oid (or empty) without changing anything.
merged_oid() {
gh_retry gh pr view "$pr" -R "$slug" --json state,mergeCommit 2>/dev/null \
| jq -r 'if .state == "MERGED" then (.mergeCommit.oid // "merged") else "" end' 2>/dev/null
}
# Already merged? Report success and stop (never re-merge).
oid="$(merged_oid)"
if [ -n "$oid" ]; then
echo "already merged: $slug #$pr ($oid)"
exit 0
fi
# --- Safety gate: re-confirm the terminal condition from a NON-degraded snapshot ---
if [ "$force" -ne 1 ]; then
state_json="$(bash "$dir/pr-state.sh" "$pr" "$slug" 2>/dev/null)" || true
ready="$(printf '%s' "$state_json" | jq -r '.ready_to_merge // false' 2>/dev/null)"
if [ "$ready" != "true" ]; then
echo "pr-merge: REFUSING — PR #$pr is not ready to merge." >&2
printf '%s' "$state_json" | jq -r '
" threads_fetched: \(.threads_fetched)",
" ci_all_pass: \(.ci_all_pass) failing: \(.ci_failing)",
" review_bot: \(.review_bot_checks|map(.bucket)|join("/"))",
" unresolved: \(.review_threads_unresolved)",
" mergeable: \(.mergeable) / \(.mergeStateStatus)"' 2>/dev/null >&2
echo " (if threads_fetched is false this is a transient read — retry; otherwise resolve the blocker)" >&2
exit 3
fi
[ -z "$subject" ] && subject="$(printf '%s' "$state_json" | jq -r '.title' 2>/dev/null) (#$pr)"
fi
[ -z "$subject" ] && subject="$(gh_retry gh pr view "$pr" -R "$slug" --json title -q .title) (#$pr)"
# --- Preserve DCO: append a Signed-off-by trailer from the PR's commits if absent ---
if ! printf '%s' "$body" | grep -qi '^Signed-off-by:'; then
commits="$(gh_retry gh api "repos/$slug/pulls/$pr/commits")" || true
signoff="$(printf '%s' "$commits" \
| jq -r '[ .[].commit.message | scan("(?m)^Signed-off-by:.*$") ] | unique | join("\n")' 2>/dev/null)" || true
if [ -n "$signoff" ]; then
if [ -n "$body" ]; then body="$body
$signoff"; else body="$signoff"; fi
fi
fi
# --- Merge (remote-only via -R, through the transient-retry wrapper) ---
margs=(gh pr merge "$pr" -R "$slug" "--$strategy")
[ "$delete_branch" -eq 1 ] && margs+=(--delete-branch)
case "$strategy" in
squash|merge) margs+=(--subject "$subject" --body "$body") ;;
esac
gh_retry "${margs[@]}" >/dev/null 2>&1 || true
# --- Idempotent verify-after: the merge is a server op; trust the resulting state,
# not the merge call's exit code (it can fatal on local fast-forward yet land). ---
oid="$(merged_oid)"
if [ -n "$oid" ]; then
echo "merged: $slug #$pr ($oid)"
exit 0
fi
echo "pr-merge: merge did not land for $slug #$pr — re-run pr-state.sh and inspect." >&2
exit 1
#!/usr/bin/env bash
# Reply inside a review thread to dispute a false positive, robustly. The body is
# read from stdin (or a file), NEVER passed inline — that sidesteps the shell
# quoting hazards that broke real sessions ("(eval): unmatched '" on an apostrophe
# or backtick in the reasoning). Replying is a POST (not idempotent), so this does
# NOT blindly retry: it posts once, and only on a transient error re-checks whether
# the reply actually landed before trying again — so a flaky "EOF" never double-posts.
#
# Usage:
# pr-comments.sh prints each finding's "reply-to id"; pass it here.
# echo "..." | pr-reply.sh <PR> <REPLY_TO_ID> [OWNER/REPO]
# pr-reply.sh <PR> <REPLY_TO_ID> [OWNER/REPO] --body-file note.md
#
# Convention: address the bot so it re-evaluates, e.g. start the body with
# "@coderabbitai " and give a concise, code-grounded reason. This script does not
# add the mention for you — write it into the body.
#
# Exit: 0 posted (or already replied); 1 error; 2 usage.
# Requires: gh (authenticated), jq, sibling gh-retry.sh.
set -uo pipefail
dir="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=/dev/null
. "$dir/gh-retry.sh"
pr=""; reply_to=""; repo="${GH_REPO:-}"; body_file=""
while [ $# -gt 0 ]; do
case "$1" in
--body-file) [ $# -ge 2 ] || { echo "missing value for --body-file" >&2; exit 2; }; body_file="$2"; shift 2 ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*/*) repo="$1"; shift ;;
*) if [ -z "$pr" ]; then pr="$1"; elif [ -z "$reply_to" ]; then reply_to="$1"; else repo="$1"; fi; shift ;;
esac
done
if [ -z "$pr" ] || [ -z "$reply_to" ]; then
echo "usage: pr-reply.sh <PR> <REPLY_TO_ID> [OWNER/REPO] [--body-file F] (body on stdin otherwise)" >&2
exit 2
fi
case "$reply_to" in ''|*[!0-9]*)
echo "pr-reply: REPLY_TO_ID must be a numeric comment id (the 'reply-to id' from pr-comments.sh)" >&2
exit 2 ;;
esac
if [ -n "$repo" ]; then
owner="${repo%%/*}"; name="${repo##*/}"
else
ownername="$(gh repo view --json owner,name -q '.owner.login + " " + .name' 2>/dev/null)" || true
owner="${ownername%% *}"; name="${ownername##* }"
fi
slug="$owner/$name"
# Resolve the body into a file so a retry can re-post without re-reading stdin.
cleanup=""
if [ -z "$body_file" ]; then
body_file="$(mktemp)"; cleanup="$body_file"; cat > "$body_file"
fi
trap '[ -n "$cleanup" ] && rm -f "$cleanup"' EXIT
if [ ! -s "$body_file" ]; then
echo "pr-reply: empty body (write the reply to stdin or pass --body-file)" >&2
exit 2
fi
me="$(gh_retry gh api user -q .login 2>/dev/null)" || me=""
# Existing reply by me to this comment? (idempotency / post-failure verification)
reply_exists() {
[ -z "$me" ] && return 1
gh_retry gh api "repos/$slug/pulls/$pr/comments" --paginate \
| jq -s 'add // []' 2>/dev/null \
| jq -r --arg me "$me" --argjson rid "$reply_to" \
'[ .[] | select((.in_reply_to_id == $rid) and (.user.login == $me)) ] | (.[-1].html_url // empty)' 2>/dev/null
}
ex="$(reply_exists)" || true
if [ -n "$ex" ]; then
echo "already replied: $ex"
exit 0
fi
# Post once; retry ONLY a transient failure, and only after confirming the prior
# attempt did not actually land (so EOF-after-success never double-posts).
err="$(mktemp)"; trap '[ -n "$cleanup" ] && rm -f "$cleanup"; rm -f "$err"' EXIT
attempt=1
while [ "$attempt" -le 3 ]; do
resp="$(gh api "repos/$slug/pulls/$pr/comments/$reply_to/replies" -F body=@"$body_file" 2>"$err")" || true
if printf '%s' "$resp" | jq -e '.id' >/dev/null 2>&1; then
printf '%s' "$resp" | jq -r '"replied: \(.html_url) (id \(.id))"'
exit 0
fi
# The POST failed to ACK. Before even considering a retry, let a committed-but-
# unacked write become visible (GitHub read-after-write lag) and re-check — so a
# dropped response on a reply that DID land never causes a double-post. Without a
# readable author ($me empty) we cannot verify, so we never retry a POST blind.
[ -z "$me" ] && break
sleep "$((2 * attempt))"
ex="$(reply_exists)" || true
if [ -n "$ex" ]; then
echo "replied (confirmed after a transient error): $ex"
exit 0
fi
if grep -qiE 'EOF|timed? ?out|50[234]|connection reset|temporarily unavailable' "$err"; then
attempt="$((attempt + 1))"; continue
fi
break
done
echo "pr-reply: failed to post reply to comment $reply_to in $slug #$pr:" >&2
cat "$err" >&2
exit 1
#!/usr/bin/env bash
# One-shot health snapshot of a GitHub PR as compact JSON: CI checks,
# reviewer-bot status checks, review-thread counts (incl. unresolved),
# mergeability, and a single ready_to_merge boolean.
#
# Usage: pr-state.sh [PR_NUMBER] [OWNER/REPO]
# PR_NUMBER defaults to the current branch's PR (requires running in the repo)
# OWNER/REPO defaults to $GH_REPO, else the repo of the current directory
#
# Requires: gh (authenticated), jq.
set -uo pipefail
# Retry transient GitHub API failures (EOF/5xx) so a flaky read never degrades the
# snapshot. gh_retry captures stderr to detect transients, so wrapped calls drop
# their own `2>/dev/null`.
dir="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=/dev/null
. "$dir/gh-retry.sh"
pr="${1:-}"
repo="${2:-${GH_REPO:-}}"
# Build the -R flag as an array. The `${R[@]+...}` guard keeps the expansion
# safe under `set -u` on macOS's Bash 3.2, where a bare "${R[@]}" on an empty
# array aborts with "R[@]: unbound variable".
R=()
[ -n "$repo" ] && R=(-R "$repo")
if [ -z "$pr" ]; then
pr="$(gh pr view "${R[@]+"${R[@]}"}" --json number -q .number 2>/dev/null)" || true
fi
if [ -z "$pr" ]; then
echo '{"error":"no PR (pass a PR number, and OWNER/REPO unless run inside the repo)"}'
exit 0
fi
# Owner/name for the GraphQL query.
if [ -n "$repo" ]; then
owner="${repo%%/*}"
name="${repo##*/}"
else
ownername="$(gh repo view --json owner,name -q '.owner.login + " " + .name' 2>/dev/null)" || true
owner="${ownername%% *}"
name="${ownername##* }"
fi
# Status checks. bucket: pass|fail|pending|skipping|cancel. `gh pr checks` exits
# non-zero when checks fail/pend but still prints JSON, so ignore the exit code.
checks="$(gh_retry gh pr checks "$pr" "${R[@]+"${R[@]}"}" --json name,bucket,state)" || true
[ -z "$checks" ] && checks='[]'
prview="$(gh_retry gh pr view "$pr" "${R[@]+"${R[@]}"}" --json mergeable,mergeStateStatus,reviewDecision,state,title,headRefName,headRefOid)" || true
[ -z "$prview" ] && prview='{}'
threads="$(gh_retry gh api graphql -F owner="$owner" -F repo="$name" -F pr="$pr" -f query='
query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$pr){
reviewThreads(first:100){nodes{isResolved isOutdated comments(first:1){totalCount}}}
}
}
}')" || true
# A failed/partial threads fetch (e.g. a transient GraphQL EOF) must NOT be read
# as "0 unresolved" — that would fabricate ready_to_merge=true and green-light a
# merge while real unresolved review threads are hidden. A genuinely empty PR
# still returns a non-null (possibly empty) nodes array, so this only trips on an
# actual fetch failure, never on a real zero-thread PR.
threads_ok=true
if [ -z "$threads" ] \
|| ! printf '%s' "$threads" | jq -e '.data.repository.pullRequest.reviewThreads.nodes != null' >/dev/null 2>&1; then
threads_ok=false
fi
[ -z "$threads" ] && threads='{}'
# Status-check names treated as "review bot" rather than CI. Extend as needed.
botre='coderabbit|sourcery|codium|qodo|greptile|ellipsis'
jq -n \
--argjson checks "$checks" \
--argjson pr "$prview" \
--argjson threads "$threads" \
--argjson threads_ok "$threads_ok" \
--arg botre "$botre" \
--arg num "$pr" '
($checks // []) as $c |
($c | map(select(.name | ascii_downcase | test($botre)))) as $bot |
($c | map(select(.name | ascii_downcase | test($botre) | not))) as $ci |
(($threads.data.repository.pullRequest.reviewThreads.nodes) // []) as $t |
# Vacuously true when the repo has no non-bot CI checks (jq all over an empty
# array is true). No length>0 requirement: a repo can legitimately have zero
# CI, and ready_to_merge still gates on mergeStateStatus==CLEAN, which GitHub
# reports as UNSTABLE/BLOCKED while checks are pending or failing.
($ci | all(.bucket == "pass" or .bucket == "skipping")) as $cipass |
($bot | all(.bucket == "pass" or .bucket == "skipping")) as $botpass |
($t | map(select(.isResolved == false)) | length) as $unresolved |
{
pr: ($num | tonumber),
state: $pr.state,
title: $pr.title,
mergeable: $pr.mergeable,
mergeStateStatus: $pr.mergeStateStatus,
head: $pr.headRefOid,
reviewDecision: $pr.reviewDecision,
checks: ($c | map({name, bucket})),
ci_check_count: ($ci | length),
ci_all_pass: $cipass,
ci_failing: ($ci | map(select(.bucket == "fail")) | map(.name)),
ci_pending: ($ci | map(select(.bucket == "pending")) | map(.name)),
review_bot_checks: ($bot | map({name, bucket})),
threads_fetched: $threads_ok,
review_threads_total: (if $threads_ok then ($t | length) else null end),
review_threads_unresolved: (if $threads_ok then $unresolved else null end),
review_comment_count: (if $threads_ok then ($t | map(.comments.totalCount) | add // 0) else null end),
ready_to_merge: (
$threads_ok and $cipass and $botpass and ($unresolved == 0)
and ($pr.mergeable == "MERGEABLE")
and ($pr.mergeStateStatus == "CLEAN")
)
}'
#!/usr/bin/env bash
# Emit a single line whenever a PR's CI / reviewer-bot / review-thread state
# changes *for real*. Built for the Monitor tool (run it with persistent: true):
# each printed line becomes one notification, so you are woken on the *transition*
# (CI finished, review posted, new comments, ready to merge) instead of on a timer.
#
# Debounced on purpose. GitHub recomputes mergeability after every push (flashing
# mergeStateStatus null/UNKNOWN) and CodeRabbit edits its comments mid-review
# (jittering the comment count and momentarily dropping its status check), and a
# transient read can briefly show unresolved=0/ready=true. Naively emitting on any
# string change wakes you on this churn — and a one-poll ready=true flash could
# even trip a premature merge. So this script:
# - carries forward the last stable mergeStateStatus when it reads null/UNKNOWN
# - treats an empty reviewer-bot check bucket as "pending" (bot vanishing
# mid-review is not a change)
# - keys the state on the PR head SHA, so a push is a real transition
# - requires a candidate state to PERSIST across 2 consecutive polls before
# emitting (so jitter and one-poll flashes never wake you), and never emits a
# line at all while the snapshot is a degraded read (threads fetch failed)
#
# Usage (inside Monitor): pr-watch.sh [PR_NUMBER] [OWNER/REPO]
# Poll interval defaults to 10s; override with PR_WATCH_INTERVAL (seconds).
#
# Requires: gh, jq, and the sibling pr-state.sh.
set -uo pipefail
pr="${1:-}"
repo="${2:-${GH_REPO:-}}"
interval="${PR_WATCH_INTERVAL:-10}"
case "$interval" in ''|*[!0-9]*) interval=10 ;; esac # integer seconds; bad value -> default (no busy-spin)
if [ -z "$pr" ]; then
pr="$(gh pr view ${repo:+-R "$repo"} --json number -q .number 2>/dev/null)" || true
fi
dir="$(cd "$(dirname "$0")" && pwd)"
# One normalized state line. A transient degraded read (threads fetch failed)
# yields "" so we HOLD; a permanent error (no such PR, deleted/transferred) yields
# an "ERROR …" sentinel so the agent is woken instead of waiting forever.
# bot="" -> "pending".
snapshot() {
"$dir/pr-state.sh" "$pr" "$repo" 2>/dev/null | jq -r '
if (.error) then "ERROR \(.error)"
elif (.threads_fetched == false) then ""
else
(.review_bot_checks | map(.bucket) | join("/")) as $bot
| "fetched ci_pass=\(.ci_all_pass) ci_fail=\(.ci_failing|length) "
+ "bot=\(if ($bot=="") then "pending" else $bot end) "
+ "unresolved=\(.review_threads_unresolved) comments=\(.review_comment_count) "
+ "merge=\(.mergeStateStatus) head=\(.head) ready=\(.ready_to_merge)"
end' 2>/dev/null
}
# The debounce/dedup KEY is the semantic state with the comment count stripped:
# `review_comment_count` jitters continuously while CodeRabbit edits its comments,
# so keying on it would starve a real transition (it could never hold for 2 polls).
# The comment count still rides along in the emitted line for context.
keyof() { printf '%s' "${1/comments=*merge=/merge=}"; }
emitted="" # key of the last line we emitted
cand=""; cand_n=0 # current candidate key and how many consecutive polls it has held
last_merge="?" # last non-null/UNKNOWN mergeStateStatus, for carry-forward
# Seed once so the first emission is the first real, debounced change.
seed="$(snapshot)"
if [ -n "$seed" ]; then
emitted="$(keyof "$seed")"
m="${seed#*merge=}"; m="${m%% *}"
[ "$m" != "null" ] && [ "$m" != "UNKNOWN" ] && last_merge="$m"
fi
while true; do
sleep "$interval"
raw="$(snapshot)"
[ -z "$raw" ] && continue # transient degraded read — hold
case "$raw" in
ERROR*) line="$raw" ;; # permanent error — wake on it
*)
merge="${raw#*merge=}"; merge="${merge%% *}"
if [ "$merge" = "null" ] || [ "$merge" = "UNKNOWN" ]; then
line="${raw/merge=$merge/merge=$last_merge}" # carry forward last stable merge
else
line="$raw"; last_merge="$merge"
fi ;;
esac
key="$(keyof "$line")"
if [ "$key" = "$cand" ]; then
cand_n=$((cand_n + 1))
else
cand="$key"; cand_n=1 # new candidate — start its streak
fi
# Emit a state that has held for 2 polls and differs (semantically) from the last.
if [ "$cand_n" -ge 2 ] && [ "$key" != "$emitted" ]; then
echo "$line"
emitted="$key"
fi
done
#!/usr/bin/env bash
# Manage an isolated git worktree (or clone) that mirrors a PR's head commit, so
# ship-pr can read / diagnose / fix PR code without touching the user's working
# tree, index, or branch. It is always (re)synced to the *current* PR head, so
# triage and fixes act on the real PR code — never a stale local branch.
#
# Usage:
# pr-worktree.sh ensure <PR> [OWNER/REPO] # create-or-refresh; prints the path
# pr-worktree.sh remove <PR> [OWNER/REPO] # tear down
# pr-worktree.sh path <PR> [OWNER/REPO] # print the path only (no changes)
#
# Uses a detached worktree when run inside a local clone of the target repo
# (cheap — shares the object store); otherwise clones the repo into a temp dir.
# The head is checked out DETACHED so it never collides with the user's own
# checkout of the same branch. Pushing a fix back is the caller's job:
# same-repo PR: git -C "$wt" push origin HEAD:<headRefName>
# fork PR: push to the fork's repo (needs write access to the fork)
#
# Requires: gh (authenticated), git.
set -uo pipefail
cmd="${1:-}"; pr="${2:-}"; repo="${3:-${GH_REPO:-}}"
if [ -z "$cmd" ] || [ -z "$pr" ]; then
echo "usage: pr-worktree.sh ensure|remove|path <PR> [OWNER/REPO]" >&2
exit 2
fi
# gh repo view takes the repo as a positional arg (not -R), so use the explicit
# OWNER/REPO directly when given, otherwise infer from the current directory.
if [ -n "$repo" ]; then
target="$repo"
else
target="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)"
fi
if [ -z "$target" ]; then
echo "cannot resolve repo (pass OWNER/REPO, or run inside the target repo)" >&2
exit 1
fi
slug="${target//\//-}"
base="${TMPDIR:-/tmp}/ship-pr-worktrees"
wt="$base/${slug}-pr-${pr}"
case "$cmd" in
path)
echo "$wt"; exit 0 ;;
remove)
git worktree remove --force "$wt" 2>/dev/null
rm -rf "$wt" 2>/dev/null
git worktree prune 2>/dev/null
echo "removed $wt"; exit 0 ;;
ensure) : ;;
*)
echo "unknown command: $cmd" >&2; exit 2 ;;
esac
mkdir -p "$base"
# Worktree mode when the current directory IS the target repo; else clone mode.
cur="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)"
if [ "$cur" = "$target" ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git fetch -q origin "pull/${pr}/head" || { echo "fetch pull/${pr}/head failed" >&2; exit 1; }
sha="$(git rev-parse FETCH_HEAD)"
if [ -f "$wt/.git" ]; then
# Reuse: hard-reset the existing worktree to the current head.
git -C "$wt" reset -q --hard "$sha" && git -C "$wt" clean -qfd
else
git worktree prune 2>/dev/null; rm -rf "$wt" 2>/dev/null
git worktree add -q --detach "$wt" "$sha" || { echo "worktree add failed" >&2; exit 1; }
fi
else
if [ ! -d "$wt/.git" ]; then
rm -rf "$wt"
gh repo clone "$target" "$wt" -- -q || { echo "clone failed" >&2; exit 1; }
fi
git -C "$wt" fetch -q origin "pull/${pr}/head" || { echo "fetch pull/${pr}/head failed" >&2; exit 1; }
git -C "$wt" checkout -q --detach FETCH_HEAD 2>/dev/null
git -C "$wt" reset -q --hard FETCH_HEAD && git -C "$wt" clean -qfd
fi
echo "$wt"
Related skills
How it compares
Command reference for gh ship operations—not a code review rubric skill and not a hosted CI platform integration.
FAQ
Who is ship-pr for?
Developers who merge via GitHub PRs and want their coding agent to run vetted gh and GraphQL steps.
When should I use ship-pr?
During Ship when a branch PR needs check interpretation, conflict/BEHIND diagnosis, or structured reads of unresolved review threads before merge.
Is ship-pr safe to install?
It instructs network and git/gh operations against your authenticated GitHub account; review the Security Audits panel on this Prism page and scope repo access appropriately.