Remediation workflow for an existing PR. Bot threads rot: unresolved thread, PR merges, the "minor" finding was the real one. Every step below exists to close that gap.
GitHub MCP tools are preferred when loaded
(ToolSearch("select:mcp__plugin_github_github__pull_request_read")); gh api equivalents are
given inline.
1. Resolve the PR
Given a number/URL, use it. Otherwise:
gh pr view --json number,title,body,url,isDraft,baseRefName,headRefName
owner/repo come from whichever identified the PR. A supplied URL carries its own
owner/repo — parse them from it, because the PR may live in a repo the current checkout isn't
(same number, different repo, and every read and reply would land on a stranger's PR). Fall back
to git remote -v (origin) only for a bare number or the current-branch case.
No open PR for the branch → say so and stop. Don't guess.
2. Confirm both required bots reviewed
- Copilot:
copilot-pull-request-reviewer[bot] - Codex:
chatgpt-codex-connector[bot]
pull_request_read(method="get_reviews" | "get_comments", owner, repo, pullNumber)
# gh api --paginate repos/{owner}/{repo}/pulls/{pr}/reviews | .../issues/{pr}/comments
Read every page before concluding anyone is absent. gh api returns one page by default, so a
bot that reviewed after 30 other events looks missing without --paginate; the MCP tool pages
via perPage + the after cursor from pageInfo.
A missing bot is a diagnosis, not a shrug. Diagnose per bot — they have independent configurations, so one explanation rarely covers both:
Draft PR — check
isDraft. This only explains Copilot if the repo has not enabled Review draft pull requests in its ruleset; with that on, a draft is no explanation at all and you need a different cause.Pending — last push minutes ago; report the timestamp instead of "absent".
No prior review history — check whether the bot has ever commented on any PR here:
gh api -X GET search/issues -f q="repo:{owner}/{repo} commenter:app/{bot-slug}" --jq '.total_count'-X GETis required —-falone makesghPOST, and search answers with a bare404that reads like a missing repo.{bot-slug}is the login without the[bot]suffix.Report a
0as "no prior comments in this repo" — never as "not installed". A freshly installed app, or one enabled before its first eligible PR, returns exactly the same0, and calling that an installation problem sends the user to fix something that isn't broken.There is no user-token API that answers "is this app installed" (
repos/{owner}/{repo}/installationneeds a GitHub App JWT and returns401togh authcredentials). A recent review on any PR here is the practical proof — a bot that answered yesterday is installed today.You can also check whether this bot was requested, but filter by who was requested — a bare count of
review_requestedevents counts requests aimed at humans and at the other bot:gh api --paginate repos/{owner}/{repo}/issues/{pr}/timeline \ --jq '[.[] | select(.event=="review_requested") | .requested_reviewer.login // .requested_team.name] 'Read the result carefully in both directions:
- Requested and silent, while it answered other PRs in minutes → installed and not delivering. A bot-side failure, worth saying plainly.
- Never requested proves nothing on its own. Bots that review automatically never appear
here at all — Copilot shows up because a ruleset requests it, while Codex triggers on PR
open / ready-for-review / an
@codex reviewcomment and so leaves no request event.
Before declaring any automatic reviewer broken, re-trigger it manually and time the response. Posting
@codex reviewis the supported path, and a bot that answers a manual trigger in minutes after missing the automatic one has a trigger problem, not an install problem — a much more useful thing to tell the user. Send them to Settings → GitHub Apps only when nothing else explains it.Other — state the evidence. Never "reason unclear"; if inconclusive, say what you checked and what came back, so the user doesn't re-derive it.
3. Drive every unresolved thread to a conclusion
pull_request_read(method="get_review_comments", owner, repo, pullNumber)
Returns review_threads[], each with is_resolved / is_outdated / is_collapsed and its
comments — snake_case in the payload, even though the tool description spells them camelCase.
Page with perPage + after until pageInfo.hasNextPage is false; an unresolved thread on page
two counts exactly as much as one on page one.
The gh api fallback can't do this: REST /pulls/{pr}/comments returns flat comment records with
no thread resolution state. If you're on the fallback path, get it from GraphQL:
gh api graphql -f query='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){
pullRequest(number:$n){reviewThreads(first:100){nodes{id isResolved isOutdated
comments(first:100){nodes{databaseId author{login} path line body}}}}}}}' \
-f o={owner} -f r={repo} -F n={pr}
Covers all sources — both bots, humans, SonarQube, Dependabot. For each unresolved thread:
- Read the finding against the current code at that location, not the quoted hunk — code moves.
- Fix or justify per §6.
- Fixing → implement under the
code-implementationskill's loop (research, implement, verify). - Reply in that thread, never a new top-level comment.
commentIdis the numeric id from#discussion_r<id>, not the GraphQL node id:
State what changed (with commit ref once pushed) or exactly why nothing did. Leave threads unresolved unless the user says otherwise — resolution is a human checkpoint.add_reply_to_pull_request_comment(owner, repo, pullNumber, commentId, body) # gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies -f body="..."
4. Check the PR's story against its diff
pull_request_read(method="get_diff" | "get_files", owner, repo, pullNumber)
Drift runs both ways: undersold (diff does things the body never mentions — drive-by fix, dep bump, config change), oversold (body claims what the diff doesn't do, or describes a superseded approach), title scope mismatch (title vs. what the diff actually touches).
Fix via update_pull_request / gh pr edit. Metadata correction — GitHub's edit history is the
trail, so no comment needed.
5. Self-review — once per PR, not per invocation
Only fixing what bots flagged outsources your judgment. But most invocations are a status check or a follow-up on new comments, not the first pass.
Check for the marker <!-- pr-comment-review:self-reviewed --> in the PR's comments/reviews
first. Present → skip to §6 and say so ("self-review already done in ; say 'redo the
self-review' to force a fresh pass"). Run a fresh pass only if absent or explicitly requested.
Delegate the pass, scaled to risk:
- Default:
/code-reviewat default effort — single pass, proportionate. high+ only when warranted: auth/security-sensitive code, unusually large diff, or the user asked for depth. Multi-agent review costs real time and tokens.
Findings follow §6. With no existing thread to reply to, post a fresh review comment at the relevant line — the one case where a new comment is correct — so the reasoning is in-context, not just in your report. Leave the marker comment in place afterward.
6. No deferrals
Every finding — either bot, human, or §5 — gets a fix or a specific, substantive reason it won't be fixed. Neither is skippable.
"Minor", "nitpick", "irrelevant", "follow-up" are not reasons; they're what gets written when no decision was made. A real reason is independently verifiable by another engineer: touches an unrelated module, needs a design decision only the user can make, depends on unlanded work, would break an API this PR doesn't own.
A finding that needs a genuine tradeoff decision → stop and ask. Never invent a justification to keep moving.
7. Verify, commit, report
Run the repo's verification loop (make ai-checks if present, else build + lint + test +
secretlint) before committing. Atomic commits, one logical fix each, matching recent git log
conventions.
Do not push unless this turn's invocation authorizes it. Default is commit and report "ready to push".
## Reviewer presence
- Copilot: [present | absent — reason]
- Codex: [present | absent — reason]
- Other reviewers: [list | none]
## Threads addressed (N)
- [topic]: fixed in <commit> — reply posted
- [topic]: not fixed — <specific reason> — reply posted
## Title/description
- [accurate | corrected: <what changed>]
## Self-review
- [finding]: fixed in <commit> — comment posted
- [finding]: not fixed — <specific reason> — comment posted
(or "skipped — already done in <link>" | "none found")
## Needs your input
- [findings blocked on a human decision]
## Status
- N commits, [pushed | not pushed — say "push" to publish]