blamechris

/check-pr

"Address all PR review comments systematically and respond inline."

blamechris 7 1 Updated 1mo ago
GitHub

Install

npx skillscat add blamechris/chroxy/claude-skills-check-pr

Install via the SkillsCat registry.

SKILL.md

/check-pr

Address all PR review comments systematically and respond inline.

Arguments

  • $ARGUMENTS - PR number (optional, defaults to current branch's PR)

Instructions

0. Wait for Automated Reviews

Copilot review typically takes 3-5 minutes after PR creation to even begin. If you run /check-pr immediately after creating the PR, the review won't exist yet.

IMPORTANT: Do NOT skip this step. If no Copilot review exists and the PR was created recently (within 5 min), you MUST wait — otherwise you'll process zero comments and miss the entire review.

PR_NUM=${1:-$(gh pr view --json number -q .number)}
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)

# Check how old the PR is
PR_AGE_SECONDS=$(gh pr view ${PR_NUM} --json createdAt \
  --jq "((now - (.createdAt | fromdateiso8601)))")

# Check Copilot review status
COPILOT_STATUS=$(gh api repos/${REPO}/pulls/${PR_NUM}/reviews \
  --jq '[.[] | select(.user.login == "copilot-pull-request-reviewer[bot]")] | if length == 0 then "NOT_FOUND" elif (any(.[]; .state == "PENDING")) then "IN_PROGRESS" else "COMPLETED" end')

# If no review exists yet AND PR is less than 5 min old, wait for it to appear
if [ "$COPILOT_STATUS" = "NOT_FOUND" ] && [ "${PR_AGE_SECONDS%.*}" -lt 300 ]; then
  echo "PR is ${PR_AGE_SECONDS%.*}s old. Copilot review not yet started. Waiting (polls every 30s, max 5 min)..."
  for i in $(seq 1 10); do
    sleep 30
    COPILOT_STATUS=$(gh api repos/${REPO}/pulls/${PR_NUM}/reviews \
      --jq '[.[] | select(.user.login == "copilot-pull-request-reviewer[bot]")] | if length == 0 then "NOT_FOUND" elif (any(.[]; .state == "PENDING")) then "IN_PROGRESS" else "COMPLETED" end')
    [ "$COPILOT_STATUS" != "NOT_FOUND" ] && echo "Copilot review detected (status: $COPILOT_STATUS)" && break
  done
fi

# If review is in progress, wait for it to complete
if [ "$COPILOT_STATUS" = "IN_PROGRESS" ]; then
  echo "Copilot review in progress. Polling every 30s (max 5 min)..."
  for i in $(seq 1 10); do
    sleep 30
    COPILOT_STATUS=$(gh api repos/${REPO}/pulls/${PR_NUM}/reviews \
      --jq '[.[] | select(.user.login == "copilot-pull-request-reviewer[bot]")] | if length == 0 then "NOT_FOUND" elif (any(.[]; .state == "PENDING")) then "IN_PROGRESS" else "COMPLETED" end')
    [ "$COPILOT_STATUS" != "IN_PROGRESS" ] && break
  done
fi

1. Fetch PR Info

# Fetch all review comments (inline) — paginate to avoid truncation
gh api repos/${REPO}/pulls/${PR_NUM}/comments --paginate

# Fetch all reviews
gh api repos/${REPO}/pulls/${PR_NUM}/reviews

# Fetch issue-level comments (to check if previous check-pr already ran)
gh api repos/${REPO}/issues/${PR_NUM}/comments

2. Skip Already-Replied Comments (Idempotency)

Before processing, filter out comments that already have replies from this workflow.
This makes /check-pr safe to re-run without duplicating work.

# Fetch all review comments with reply threading info
ALL_COMMENTS=$(gh api repos/${REPO}/pulls/${PR_NUM}/comments --paginate)

# Determine the current workflow user (used to detect this workflow's replies)
WORKFLOW_USER=$(gh api user --jq .login)

# Build list of comment IDs that already have replies FROM THIS WORKFLOW (filter by author)
REPLIED_IDS=$(echo "$ALL_COMMENTS" | jq --arg user "$WORKFLOW_USER" \
  '[.[] | select(.in_reply_to_id != null and .user.login == $user) | .in_reply_to_id] | unique')

# Filter to only unprocessed top-level comments (no existing reply from this workflow)
PENDING_COMMENTS=$(echo "$ALL_COMMENTS" \
  | jq --argjson replied "$REPLIED_IDS" \
    '[.[] | select(.in_reply_to_id == null) | select([.id] | inside($replied) | not)]')

Only process comments in PENDING_COMMENTS. If all comments already have replies, report "All comments already addressed" and exit.

3. Process EVERY Pending Comment — ONE AT A TIME

For each pending review comment (Copilot or human), you MUST do ALL of these steps before moving to the next comment:

  1. Read the comment carefully
  2. Classify it into exactly ONE of the four valid outcomes below
  3. Take the required action AND post a reply

CRITICAL: The inline reply (gh api ... /comments/${COMMENT_ID}/replies) is the PRIMARY output of this skill. The summary comment is secondary. If you only post a summary without inline replies, the skill has FAILED — conversation threads will remain unresolved and block merging.

Default stance: FIX IT NOW — Only defer if the suggestion is a false positive or requires scope expansion (tracked via follow-up issue).

CRITICAL: There are ONLY FOUR valid outcomes for each comment. Every comment MUST result in one of these:

  1. FIX — Make the code change, commit, reply with commit hash + before/after code
  2. FOLD — The fix is ≤15 minutes but sits outside this PR's stated scope. Make it anyway,
    in this PR, and reply with the commit hash. A FOLD reply without a commit SHA is invalid
    — that is the "acknowledge and move on" this file has always banned, wearing a new label.
  3. FALSE POSITIVE — Reply explaining why the suggestion is incorrect, with evidence
  4. FOLLOW-UP ISSUE — Create a GitHub issue, reply with the issue URL

There is no silent "acknowledge and move on". A valid finding must end in a FIX, a FOLD, a
FALSE POSITIVE with evidence, or a FOLLOW-UP ISSUE. Never reply with "good idea, maybe later".

Which of FOLD and FOLLOW-UP ISSUE applies is decided by effort, not by provenance. A
correct, trivial finding outside this PR's abstraction belongs in FOLD. Reserve
FOLLOW-UP ISSUE for work that exceeds ~15 minutes, needs a decision, or is critical/security
severity — those are exempt and must always be filed. Note that FALSE POSITIVE cannot absorb
"correct but trivial": it requires proving the finding factually wrong, which is why, before
FOLD existed, a valid two-minute nitpick outside scope had exactly one legal destination.

REPLY FORMAT IS NON-NEGOTIABLE. Every reply MUST start with the bold label (**FIX**, **FOLD**, **FALSE POSITIVE**, or **FOLLOW-UP ISSUE**) on its own line. Replies without this label are malformed and will be rejected.

Reply Format Examples

Study these examples. Your replies must match this structure exactly.

Example: FIX reply

FIX

Fixed in 7bab8be4

Change: Added marginBottom: 4 to promptHeaderRow so spacing is owned by the row container.

- promptHeaderRow: {
-   flexDirection: 'row',
-   justifyContent: 'space-between',
-   alignItems: 'center',
- },
+ promptHeaderRow: {
+   flexDirection: 'row',
+   justifyContent: 'space-between',
+   alignItems: 'center',
+   marginBottom: 4,
+ },

Example: FALSE POSITIVE reply

FALSE POSITIVE

Reason: The remaining <= 0 in the dependency array is intentional — it acts as a boolean gate that prevents the effect from re-creating an interval once the countdown reaches zero.

Evidence:

  • Without it, the effect would restart on every expiresAt change even after expiry
  • Same pattern used in React docs for "run once then stop" effects
  • The expression evaluates to a stable true/false, not a changing number

Example: FOLLOW-UP ISSUE reply

FOLLOW-UP ISSUE

Created https://github.com/owner/repo/issues/123 to track this.

Reason for deferral: Switching from absolute expiresAt to relative remainingMs requires changing the WS protocol contract and both server broadcast paths — out of scope for this countdown UI PR.


Outcome 1: FIX IMMEDIATELY (default)

When the comment identifies a real issue, fix it immediately.

Required in reply: commit hash AND before/after code diff. Both are mandatory.

  1. Make the code fix
  2. Commit with descriptive message (NO attribution — no Co-Authored-By, no "Generated with", no AI mentions)
  3. Reply inline with the EXACT format below:
gh api repos/${REPO}/pulls/${PR_NUM}/comments/${COMMENT_ID}/replies \
  --method POST \
  -f body="**FIX**

Fixed in \`${COMMIT_SHA}\`

**Change:** Brief description of fix

\`\`\`diff
- old_code_line
+ new_code_line
\`\`\`"

NEVER post a fix reply without the commit SHA and a code diff. If you fixed it, prove it.


Outcome 2: FALSE POSITIVE (evidence REQUIRED)

Only use this if the suggestion is factually incorrect. You MUST provide evidence.

Required in reply: specific evidence why the comment is wrong (doc reference, code reference, or logical proof).

gh api repos/${REPO}/pulls/${PR_NUM}/comments/${COMMENT_ID}/replies \
  --method POST \
  -f body="**FALSE POSITIVE**

**Reason:** Clear explanation of why this is correct

**Evidence:**
- per CLAUDE.md: no semicolons, single quotes
- Reference to similar code in codebase"

Outcome 3: FOLLOW-UP ISSUE (GitHub issue creation MANDATORY)

When a suggestion is valid but out of scope for this PR. You MUST create a GitHub issue — never reply with just "good idea" or "noted for later" without an issue URL.

Required in reply: the created issue URL. No exceptions.

# 1. ALWAYS create the issue — this is NOT optional
# NEVER write "Created a follow-up issue" without the URL. The URL is the whole point.
ISSUE_URL=$(gh issue create \
  --title "Short descriptive title" \
  --label "enhancement" \
  --label "from-review" \
  --body "$(cat <<'EOF'
## Context

Identified during review of PR #${PR_NUM}.

## Description

What needs to be done and why.

## Original Comment

> Quote the review comment here verbatim

## Acceptance Criteria

- [ ] Criterion 1
- [ ] Criterion 2
EOF
)")

# 2. Reply inline referencing the issue — MUST include the FULL issue URL
gh api repos/${REPO}/pulls/${PR_NUM}/comments/${COMMENT_ID}/replies \
  --method POST \
  -f body="**FOLLOW-UP ISSUE**

Created ${ISSUE_URL} to track this.

**Reason for deferral:** Brief explanation why not in this PR"

INVALID outcomes (never use these):

  • "Good idea, we should do this later" without an issue URL
  • "Follow-up." or "Deferred." without a **FOLLOW-UP ISSUE** label and issue URL
  • "Intentional design decision" without evidence — use FALSE POSITIVE with evidence instead
  • "Noted" / "Acknowledged" without a FIX or ISSUE URL
  • Any reply that doesn't start with **FIX**, **FOLD**, **FALSE POSITIVE**, or **FOLLOW-UP ISSUE**
  • **FOLD** with no commit SHA, or with a SHA that is not a commit on THIS PR — that is the
    banned "acknowledge and move on" wearing a new label, and step 6c rejects it
  • Empty Reference cells in the summary table

4. Push All Fixes

git push

5. Cross-Reference Fixes Against Open Issues

After pushing fixes, check if any open from-review issues were resolved by the work in this PR. This commonly happens when Copilot feedback addresses the same problem an agent-review issue was tracking.

# List open from-review issues
gh issue list --label "from-review" --state open --limit 100 --json number,title,body,url

# For each fix, check if an open issue describes the same problem.
# If so, close it with a comment linking the PR:
gh issue comment ${ISSUE_NUM} --body "Addressed in PR #${PR_NUM} — ${DESCRIPTION}."
gh issue close ${ISSUE_NUM}

RULE: Every closed issue MUST reference a PR. The comment is the paper trail. No silent closes.

6. Verify All Inline Replies Were Posted

This step is MANDATORY. Do NOT skip it.

# Count root comments (not replies) from reviewers
ROOT_COUNT=$(gh api repos/${REPO}/pulls/${PR_NUM}/comments --paginate \
  --jq '[.[] | select(.in_reply_to_id == null)] | length')

# Count unique root comments that have at least one reply
REPLIED_COUNT=$(gh api repos/${REPO}/pulls/${PR_NUM}/comments --paginate \
  --jq '[.[] | select(.in_reply_to_id != null) | .in_reply_to_id] | unique | length')

echo "Root comments: ${ROOT_COUNT}, Replied: ${REPLIED_COUNT}"

If REPLIED_COUNT < ROOT_COUNT, you have UNREPLIED comments. Go back to step 3 and post the missing inline replies BEFORE proceeding. Do NOT post the summary comment until every thread has a reply.

6c. Verify every FOLD names a real commit on this PR

A FOLD whose SHA is not a commit on this PR is indistinguishable from the "acknowledge and
move on" this file bans.
The claim "a FOLD reply without a commit SHA is invalid" is only worth
something if something checks it, so check it — this is the one outcome whose evidence is
mechanically verifiable, and leaving it on the honour system would repeat the pattern this repo
records as every honour-system limit has failed and every mechanically-bound one has held.

# Every SHA claimed by a FOLD reply must appear in this PR's commit list.
gh pr view "$PR" --json commits -q '.commits[].oid' > /tmp/pr-shas.txt
gh api "repos/$REPO/pulls/$PR/comments" --paginate -q '.[] | select(.body | startswith("**FOLD**")) | .body' \
  | grep -oE '\b[0-9a-f]{7,40}\b' | sort -u > /tmp/fold-shas.txt

# A FOLD SHA not on this PR is a FAILURE, not a warning.
while read -r sha; do
  grep -q "^$sha" /tmp/pr-shas.txt || { echo "::error::FOLD cites $sha, which is not a commit on PR #$PR"; exit 1; }
done < /tmp/fold-shas.txt

Then, for each FOLD, confirm the cited commit actually touches the file the finding was about
— git show --stat <sha> — because a SHA copy-pasted from elsewhere in the PR passes the check
above and changes nothing relevant.

If there are no FOLD replies, /tmp/fold-shas.txt is empty and the loop runs zero times. That
is a pass over zero rows, not a pass
— say so explicitly rather than reporting the step green.

6b. Resolve Conversation Threads

This step is MANDATORY whenever branch protection requires conversation resolution before merge. Posting an inline reply does NOT auto-resolve the thread on GitHub — the REST /replies endpoint only adds a comment, leaving the thread state as isResolved: false. If you skip this step, the PR sits blocked at merge time even when every comment has a reply, every check is green, and the summary comment claims success. The user has to click "Resolve conversation" once per unresolved thread to unblock the merge. Don't make them.

GraphQL is required here — REST doesn't expose thread state. Threads are GraphQL-only objects (PRRT_* IDs); the resolveReviewThread mutation needs the GraphQL node ID, not the REST databaseId.

# Fetch all unresolved review thread IDs (GraphQL — REST doesn't expose thread
# state). --paginate auto-loops on pageInfo.hasNextPage so PRs with >100 threads
# are fully covered; without it, threads on later pages stayed unresolved AND
# unreported, so the resolve step silently appeared to succeed while the merge
# gate stayed red. --jq runs per-page and outputs are concatenated, so we emit
# one ID per line rather than building one mega-array across pages.
THREAD_IDS=$(gh api graphql --paginate -f query="
  query(\$endCursor: String) {
    repository(owner: \"${REPO%/*}\", name: \"${REPO#*/}\") {
      pullRequest(number: ${PR_NUM}) {
        reviewThreads(first: 100, after: \$endCursor) {
          nodes { id isResolved }
          pageInfo { hasNextPage endCursor }
        }
      }
    }
  }" --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | .id')

# Resolve each unresolved thread via Python — pass the Base64-ish thread ID
# (PRRT_*) as a GraphQL *variable* (-f id=...) so it never gets interpolated
# into the query string or the shell (merge.md Critical Rule 4). The
# --paginate THREAD_IDS fetch above stays in bash (it only emits IDs). gh
# exits 0 even when the GraphQL response body carries an `errors` array, so
# validate the parsed response's isResolved rather than the exit code;
# surface each failure per-thread.
echo "$THREAD_IDS" | python3 -c "
import sys, subprocess, json
q = 'mutation(\$id: ID!) { resolveReviewThread(input: {threadId: \$id}) { thread { isResolved } } }'
for tid in sys.stdin.read().split():
    r = subprocess.run(['gh', 'api', 'graphql', '-f', 'query=' + q, '-f', 'id=' + tid], capture_output=True, text=True)
    ok = False
    if r.returncode == 0:
        try:
            d = json.loads(r.stdout)
            ok = 'errors' not in d and d['data']['resolveReviewThread']['thread']['isResolved'] is True
        except (ValueError, KeyError, TypeError):
            ok = False
    print('  resolved: ' + tid if ok else '  FAILED to resolve: ' + tid)
"

# Verify zero unresolved threads remain. --paginate emits one length per page,
# which we sum with awk so the count is correct on PRs with >100 threads. If
# this stays nonzero, either the resolve loop failed on specific threads or new
# threads landed mid-flight — re-run step 6b.
UNRESOLVED=$(gh api graphql --paginate -f query="
  query(\$endCursor: String) {
    repository(owner: \"${REPO%/*}\", name: \"${REPO#*/}\") {
      pullRequest(number: ${PR_NUM}) {
        reviewThreads(first: 100, after: \$endCursor) {
          nodes { isResolved }
          pageInfo { hasNextPage endCursor }
        }
      }
    }
  }" --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' \
  | awk '{s+=$1} END {print s+0}')

echo "Unresolved threads: ${UNRESOLVED}"
[ "$UNRESOLVED" -eq 0 ] || { echo "FAIL: ${UNRESOLVED} threads still unresolved"; exit 1; }

Pagination cap: gh api graphql --paginate follows pageInfo.hasNextPage until exhausted — no implicit cap. On the rare PR with thousands of threads, GitHub's GraphQL rate limit (5000 points/hr) is the practical ceiling. If you see HTTP 403 with "API rate limit exceeded" from gh on step 6b, the resolve loop will short-circuit on the failing call and the verify will report nonzero — re-run after the rate limit window resets.

When to skip this step: only if the repo's branch protection does NOT require conversation resolution AND you have explicit evidence (e.g., a memory/customization note) that unresolved threads are acceptable here. Default behavior is always resolve. Chroxy branch protection REQUIRES conversation resolution — do NOT skip.

Edge cases:

  • A thread you marked FALSE POSITIVE: still resolve it. The reply records the rationale; if a reviewer disagrees, they can re-open the thread.
  • A FOLLOW-UP ISSUE thread: still resolve it. The issue link in the reply is the paper trail; the conversation in the PR has served its purpose.
  • A FIX thread: resolve it after the fix commit lands and the reply with the commit SHA is posted.

7. Post Summary Comment

After addressing ALL comments, post a summary on the PR. Every row MUST have a commit hash or issue URL — no empty cells.

gh pr comment ${PR_NUM} --body "$(cat <<'EOF'
## Review Comments Addressed

| # | Comment | Outcome | Reference |
|---|---------|---------|-----------|
| 1 | Comment 1 summary | FIX | `abc1234` |
| 2 | Comment 2 summary | FALSE POSITIVE | Evidence: [brief] |
| 3 | Comment 3 summary | FOLLOW-UP | [#456](https://github.com/blamechris/chroxy/issues/456) |

**Total:** X comments addressed
- Fixed: Y (commit hashes above)
- False positives: Z (with evidence)
- Follow-up issues created: V (linked above)
- Existing issues closed: W
EOF
)"

Summary table rules:

  • The Reference column must NEVER be empty
  • FIX rows: commit hash (e.g., abc1234)
  • FALSE POSITIVE rows: brief evidence summary
  • FOLLOW-UP rows: issue URL or auto-linked issue number (e.g., #456)

8. Report to User

Output a summary table followed by details. The table is the PRIMARY output — it must be scannable at a glance.

| PR | Comments | Changes | Issues |
|----|----------|---------|--------|
| #XX | N → Y fixed, Z false pos | brief change 1, brief change 2 | Created: #A, #B. Closed: #C |

Column guide:

  • Comments: N → Y fixed (and , Z false pos / , W deferred if any)
  • Changes: Comma-separated brief descriptions of what changed (2-5 words each). Works for fixes, features, refactors — keep it generic.
  • Issues: Created: #X, #Y for new follow-up issues. Closed: #Z for resolved from-review issues. — if none.

Then below the table, list:

  • Full commit hashes for each fix
  • Reasons for any false positives
  • URLs for created/closed issues
  • PR ready for re-review: Yes/No

Critical Rules

  1. EVERY pending comment gets a reply — No silent dismissals. The gh api .../replies call is the MOST IMPORTANT output. A summary comment WITHOUT inline replies is a FAILURE.
  2. Reply IMMEDIATELY after each comment — Process one comment at a time: read → fix/defer → post inline reply → next. Do NOT batch all fixes and try to reply later.
  3. Exactly 4 valid outcomes — FIX, FOLD, FALSE POSITIVE, or FOLLOW-UP ISSUE. Nothing else.
  4. FIX requires commit hash + code diff — Both mandatory in reply
    4b. FOLD requires a commit SHA that is on THIS PR — verified mechanically in step 6c.
    FOLD is for a valid finding whose fix is ≤15 min but outside this PR's stated scope:
    make the change here, name it, and say in the reply that scope was deliberately widened.
  5. FALSE POSITIVE requires evidence — No bare dismissals
  6. FOLLOW-UP requires issue URL — Never say "good idea" without creating an issue
  7. Summary table has no empty cells — Every row has a reference
  8. Verify before summarizing — Run the verification step (step 6) and confirm all threads have replies BEFORE posting the summary comment. If any are missing, go back and post them.
  9. Resolve every thread (step 6b) — Posting a reply does NOT mark the thread resolved on GitHub. After replying to every thread, call the GraphQL resolveReviewThread mutation for each. Branch protection that requires conversation resolution will block merge otherwise — silently, from the user's perspective. Skip this only with explicit per-repo evidence that unresolved threads are acceptable.
  10. Idempotent — Safe to re-run; already-replied comments are skipped (author-filtered). Already-resolved threads are also skipped in step 6b.
  11. No attribution — Follow Zero Attribution Policy (no Co-Authored-By, no "Generated with Claude", no AI mentions anywhere)

Example Workflow

1. Run /check-pr 42
2. Poll Copilot review... ready (state: COMPLETED)
3. Fetch 5 comments, 2 already replied → 3 pending
4. Comment A: "Missing null check on line 45"
   → Outcome: FIX
   → Commit fix, reply with **FIX** + hash + before/after diff
5. Comment B: "This variable seems unused"
   → Outcome: FALSE POSITIVE
   → Reply with **FALSE POSITIVE** + evidence: "Used on line 78 in _process()"
6. Comment C: "Add retry logic for network calls"
   → Outcome: FOLLOW-UP ISSUE
   → Create issue #99 with labels, reply with **FOLLOW-UP ISSUE** + URL
7. Push fixes
8. Verify all threads have replies (step 6)
9. Resolve all conversation threads via GraphQL (step 6b)
10. Post summary table (all Reference cells filled)
11. Report to user