Skip to content

wheels-bot embeds a stale SHA in review idempotency markers, causing review re-fires #2015

wheels-bot embeds a stale SHA in review idempotency markers, causing review re-fires

wheels-bot embeds a stale SHA in review idempotency markers, causing review re-fires #2015

Workflow file for this run

name: Wheels Bot — Reviewer A
# Two trigger paths into this workflow:
# 1. pull_request — the standard path (PR opens, syncs, or marks ready).
# Reviewer A submits its initial review of the diff via /review-pr.
# 2. issue_comment — the convergence-loop path. When Reviewer B posts a
# not-yet-aligned critique (matches `wheels-bot:review-b:` but NOT
# `wheels-bot:converged-` and NOT `:terminal`), A responds via
# /respond-to-critique. The response is itself a review (state=COMMENT),
# which triggers Reviewer B's next round — loop continues until B
# emits a converged-* marker or the round cap fires.
on:
pull_request:
types: [opened, synchronize, ready_for_review]
branches: [develop]
issue_comment:
types: [created]
permissions:
contents: read
concurrency:
group: wheels-bot-review-a-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: false
jobs:
review:
name: Reviewer A
runs-on: ubuntu-latest
timeout-minutes: 20
# Initial review: bot PRs (even draft) OR human ready-for-review PRs.
# Response: bot's own comment from Reviewer B that is NOT a converged
# marker AND NOT terminal — i.e., B is signalling "more discussion
# needed."
# The bot-identity check on the comment path is load-bearing: prevents
# humans from quoting a marker in a reply to trigger the response.
if: |
vars.WHEELS_BOT_ENABLED == 'true'
&& (
(github.event_name == 'pull_request'
&& (github.event.pull_request.user.login == 'wheels-bot[bot]'
|| github.event.pull_request.draft == false))
|| (github.event_name == 'issue_comment'
&& github.event.comment.user.login == 'wheels-bot[bot]'
&& contains(github.event.comment.body, 'wheels-bot:review-b:')
&& !contains(github.event.comment.body, 'wheels-bot:converged-')
&& !contains(github.event.comment.body, ':terminal'))
)
steps:
- name: Generate App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.WHEELS_BOT_APP_ID }}
private-key: ${{ secrets.WHEELS_BOT_PRIVATE_KEY }}
- name: Resolve PR info
id: pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_FROM_PR_EVENT: ${{ github.event.pull_request.number }}
PR_FROM_COMMENT_EVENT: ${{ github.event.issue.number }}
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
pr_num="$PR_FROM_PR_EVENT"
sha="${{ github.event.pull_request.head.sha }}"
mode="initial"
else
pr_num="$PR_FROM_COMMENT_EVENT"
# Validate numeric.
if ! [[ "$pr_num" =~ ^[0-9]+$ ]]; then
echo "::error::Invalid PR number from issue context: $pr_num"
exit 1
fi
sha=$(gh pr view "$pr_num" --repo wheels-dev/wheels --json headRefOid -q '.headRefOid')
mode="response"
fi
echo "pr_num=$pr_num" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "mode=$mode" >> "$GITHUB_OUTPUT"
- name: Checkout PR head
uses: actions/checkout@v6
with:
ref: ${{ steps.pr.outputs.sha }}
fetch-depth: 0
- name: Skip check (initial review only — response mode handles its own idempotency)
if: github.event_name == 'pull_request'
id: gate
uses: ./.github/actions/wheels-bot-skip-check
with:
target-type: pr
target-number: ${{ steps.pr.outputs.pr_num }}
# No trailing colon: the initial review marker is
# `wheels-bot:review-a:<pr>:<sha>` with no suffix. Adding a trailing
# `:` made this a no-op gate (issue #2558). Response markers use the
# distinct `wheels-bot:review-a-response:` prefix and won't false-match.
marker-pattern: 'wheels-bot:review-a:${{ steps.pr.outputs.pr_num }}:${{ steps.pr.outputs.sha }}'
github-token: ${{ steps.app-token.outputs.token }}
- name: Determine prompt
id: cmd
run: |
if [ "${{ steps.pr.outputs.mode }}" = "initial" ]; then
echo "cmd=/review-pr ${{ steps.pr.outputs.pr_num }}" >> "$GITHUB_OUTPUT"
else
echo "cmd=/respond-to-critique ${{ steps.pr.outputs.pr_num }}" >> "$GITHUB_OUTPUT"
fi
- name: Run Reviewer A
if: github.event_name == 'issue_comment' || steps.gate.outputs.skip == 'false'
uses: anthropics/claude-code-action@v1
with:
# Allows the App's bot identity (and github-actions[bot] for
# consistency). Reviewer A runs on bot's own PRs and responds
# to its own Reviewer B comments — both legitimately bot-driven.
allowed_bots: 'wheels-bot[bot],github-actions[bot]'
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.app-token.outputs.token }}
prompt: |
${{ steps.cmd.outputs.cmd }}
claude_args: |
--model claude-sonnet-4-6
--max-turns 250
--allowedTools "Bash(gh:*),Bash(git log:*),Bash(git diff:*),Bash(git show:*),Bash(git grep:*),Bash(git status),Read,Grep,Glob"
# Post-submission guard (issue #2558). Reviewer A is trusted to issue a
# single, substantive `gh pr review` per session. When the model misbehaves
# — e.g. probing the CLI with `--body "test body"` before issuing the
# real review — the placeholder leaks out as a public review. This step
# scans wheels-bot reviews on the current SHA after the model exits and
# auto-dismisses any that look like probes (too short, or missing the
# canonical `wheels-bot:review-a` marker). Runs on `always()` so it still
# fires when the Claude step itself failed mid-session.
- name: Validate Reviewer A output
if: always() && (github.event_name == 'issue_comment' || steps.gate.outputs.skip == 'false')
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ steps.pr.outputs.pr_num }}
HEAD_SHA: ${{ steps.pr.outputs.sha }}
run: |
set -euo pipefail
# Allow-list APPROVED and CHANGES_REQUESTED only: those are the
# exclusive set of states that (a) GitHub's dismiss API accepts and
# (b) gate merging. Feeding the dismiss API any other state returns
# HTTP 422 — e.g. "Can not dismiss a commented pull request review"
# for COMMENTED, the same shape for PENDING — which would fail this
# step and red-X the check. Originally observed on PR #2795 commit
# 0db188a5 when a COMMENTED placeholder leaked through.
reviews=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \
| jq -c --arg sha "$HEAD_SHA" \
'[.[] | select(.user.login == "wheels-bot[bot]") | select(.commit_id == $sha) | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")]')
count=$(echo "$reviews" | jq 'length')
if [[ "$count" == "0" ]]; then
echo "::notice::No active wheels-bot reviews on ${HEAD_SHA} to validate"
exit 0
fi
# Dismissal criteria: body must be (a) >= 200 chars AND (b) contain
# `wheels-bot:review-a` as a substring. Both conditions are required —
# a long body without the marker still gets dismissed, because a
# marker-less review breaks downstream idempotency (the skip-check
# action greps for the marker on subsequent runs) and is itself a
# signal that the prompt was not followed. Belt-and-suspenders: the
# guard treats prompt compliance as load-bearing, not advisory.
dismissed=0
while IFS= read -r row; do
id=$(echo "$row" | jq -r '.id')
body=$(echo "$row" | jq -r '.body')
body_len=${#body}
if [[ "$body_len" -lt 200 ]] || ! grep -q 'wheels-bot:review-a' <<<"$body"; then
echo "::warning::Dismissing bogus Reviewer A review id=${id} len=${body_len}"
gh api -X PUT \
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${id}/dismissals" \
-f message="Auto-dismissed by Reviewer A guard: body is shorter than 200 characters or missing the canonical \`wheels-bot:review-a\` marker. See wheels-dev/wheels#2558 for context."
dismissed=$((dismissed + 1))
fi
done < <(echo "$reviews" | jq -c '.[]')
if [[ "$dismissed" -gt 0 ]]; then
# Idempotency check: a manual workflow re-trigger combined with
# new bogus reviews on the same SHA could otherwise produce
# duplicate guard comments. Skip if a guard comment for this
# exact PR + SHA already exists.
guard_marker="wheels-bot:review-a-guard:${PR_NUMBER}:${HEAD_SHA}"
existing=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
| jq -r --arg m "$guard_marker" '[.[] | select(.body | contains($m))] | length')
if [[ "$existing" == "0" ]]; then
short_sha=${HEAD_SHA:0:7}
gh pr comment "$PR_NUMBER" --body "## Wheels Bot — Reviewer A guard
Detected and dismissed ${dismissed} bogus Reviewer A review(s) on commit \`${short_sha}\`. Cause: review body shorter than 200 characters or missing the canonical \`wheels-bot:review-a\` marker. See [wheels-dev/wheels#2558](https://github.com/wheels-dev/wheels/issues/2558) for context.
<!-- ${guard_marker} -->"
else
echo "::notice::Guard comment already present for ${PR_NUMBER}@${HEAD_SHA}; skipping duplicate"
fi
fi