-
-
Notifications
You must be signed in to change notification settings - Fork 108
203 lines (185 loc) · 9.69 KB
/
Copy pathbot-review-a.yml
File metadata and controls
203 lines (185 loc) · 9.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
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
# COMMENTED reviews are filtered out: GitHub's dismiss API returns 422
# ("Can not dismiss a commented pull request review") for them, which
# would fail this step and red-X the check. Only APPROVED and
# CHANGES_REQUESTED reviews can be dismissed — and those are also the
# only states that gate merging, so COMMENTED placeholders are noise
# the guard shouldn't act on. Observed on PR #2795 commit 0db188a5.
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 != "DISMISSED") | select(.state != "COMMENTED")]')
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