Skip to content

Commit 38a37dd

Browse files
committed
Claude PR react on review
1 parent 824e74c commit 38a37dd

File tree

1 file changed

+353
-0
lines changed

1 file changed

+353
-0
lines changed
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
name: "Claude PR Review"
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
pr_number:
7+
description: "Pull request number"
8+
required: true
9+
type: number
10+
review_id:
11+
description: "Pull request review ID"
12+
required: true
13+
type: number
14+
workflow_call:
15+
inputs:
16+
pr_number:
17+
description: "Pull request number"
18+
required: true
19+
type: number
20+
review_id:
21+
description: "Pull request review ID"
22+
required: true
23+
type: number
24+
25+
concurrency:
26+
group: claude-pr-review-${{ inputs.pr_number }}-${{ inputs.review_id }}
27+
cancel-in-progress: false
28+
29+
permissions:
30+
contents: read
31+
32+
jobs:
33+
respond:
34+
runs-on: ubuntu-latest
35+
timeout-minutes: 30
36+
permissions:
37+
contents: write
38+
pull-requests: write
39+
steps:
40+
- name: Harden the runner (Audit all outbound calls)
41+
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
42+
with:
43+
egress-policy: audit
44+
45+
- name: Fetch PR and review details
46+
id: review
47+
env:
48+
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }}
49+
run: |
50+
PR_NUM="${{ inputs.pr_number }}"
51+
REPO="phpstan/phpstan-src"
52+
53+
# Core PR and review data
54+
gh api "repos/$REPO/pulls/$PR_NUM" > /tmp/pr.json
55+
gh api "repos/$REPO/pulls/$PR_NUM/reviews/${{ inputs.review_id }}" > /tmp/review.json
56+
gh api "repos/$REPO/pulls/$PR_NUM/reviews/${{ inputs.review_id }}/comments" --paginate > /tmp/review-comments.json
57+
58+
# Additional context: all comments, all reviews, and PR diff
59+
gh api "repos/$REPO/issues/$PR_NUM/comments" --paginate > /tmp/pr-comments.json
60+
gh api "repos/$REPO/pulls/$PR_NUM/reviews" --paginate > /tmp/all-reviews.json
61+
gh api "repos/$REPO/pulls/$PR_NUM" -H "Accept: application/vnd.github.diff" > /tmp/pr.diff
62+
63+
# Fetch linked issues referenced in the PR body
64+
pr_body=$(jq -r '.body // ""' /tmp/pr.json)
65+
echo '[]' > /tmp/linked-issues.json
66+
if [ -n "$pr_body" ]; then
67+
# Match patterns like #1234, phpstan/phpstan#1234, phpstan/phpstan-src#1234,
68+
# and full GitHub issue/PR URLs
69+
issue_refs=$(echo "$pr_body" | grep -oE '(phpstan/(phpstan-src|phpstan))?#[0-9]+|https://github\.com/phpstan/(phpstan|phpstan-src)/issues/[0-9]+' | sort -u || true)
70+
issues_array="[]"
71+
for ref in $issue_refs; do
72+
# Normalize to owner/repo and number
73+
if echo "$ref" | grep -qE '^https://'; then
74+
issue_repo=$(echo "$ref" | grep -oE 'phpstan/(phpstan|phpstan-src)')
75+
issue_num=$(echo "$ref" | grep -oE '[0-9]+$')
76+
elif echo "$ref" | grep -qE '^phpstan/'; then
77+
issue_repo=$(echo "$ref" | cut -d'#' -f1)
78+
issue_num=$(echo "$ref" | cut -d'#' -f2)
79+
else
80+
# Plain #1234 - default to phpstan/phpstan (the issue tracker)
81+
issue_repo="phpstan/phpstan"
82+
issue_num=$(echo "$ref" | tr -d '#')
83+
fi
84+
85+
issue_json=$(gh api "repos/$issue_repo/issues/$issue_num" 2>/dev/null || echo "")
86+
if [ -n "$issue_json" ]; then
87+
issues_array=$(echo "$issues_array" | jq --argjson item "$issue_json" '. + [$item]')
88+
fi
89+
done
90+
echo "$issues_array" > /tmp/linked-issues.json
91+
fi
92+
93+
echo "pr_head_ref=$(jq -r '.head.ref' /tmp/pr.json)" >> "$GITHUB_OUTPUT"
94+
95+
- name: "Checkout"
96+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
97+
with:
98+
ref: ${{ steps.review.outputs.pr_head_ref }}
99+
token: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }}
100+
fetch-depth: 0
101+
102+
- name: "Install PHP"
103+
uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2
104+
with:
105+
coverage: "none"
106+
php-version: "8.4"
107+
extensions: mbstring
108+
ini-file: development
109+
ini-values: memory_limit=-1
110+
111+
- name: "Install dependencies"
112+
uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3
113+
114+
- name: "Install test dependencies"
115+
run: composer install --working-dir tests
116+
117+
- name: Build prompt
118+
id: prompt
119+
shell: bash
120+
run: |
121+
review_body=$(jq -r '.body // ""' /tmp/review.json)
122+
review_state=$(jq -r '.state' /tmp/review.json)
123+
reviewer=$(jq -r '.user.login' /tmp/review.json)
124+
num_comments=$(jq 'length' /tmp/review-comments.json)
125+
126+
prompt="A reviewer ($reviewer) submitted a review on PR #${{ inputs.pr_number }} with state: $review_state."
127+
prompt+=$'\n'
128+
129+
# --- PR description ---
130+
pr_title=$(jq -r '.title // ""' /tmp/pr.json)
131+
pr_body=$(jq -r '.body // ""' /tmp/pr.json)
132+
pr_author=$(jq -r '.user.login' /tmp/pr.json)
133+
134+
prompt+=$'\n'
135+
prompt+="## Pull request"
136+
prompt+=$'\n\n'
137+
prompt+="**#${{ inputs.pr_number }}: $pr_title** (by $pr_author)"
138+
prompt+=$'\n\n'
139+
140+
if [ -n "$pr_body" ] && [ "$pr_body" != "null" ]; then
141+
prompt+="$pr_body"
142+
prompt+=$'\n'
143+
fi
144+
145+
# --- Linked issues ---
146+
num_issues=$(jq 'length' /tmp/linked-issues.json)
147+
if [ "$num_issues" -gt 0 ]; then
148+
prompt+=$'\n'
149+
prompt+="## Linked issues"
150+
prompt+=$'\n'
151+
152+
for i in $(seq 0 $((num_issues - 1))); do
153+
issue_title=$(jq -r ".[$i].title" /tmp/linked-issues.json)
154+
issue_number=$(jq -r ".[$i].number" /tmp/linked-issues.json)
155+
issue_body=$(jq -r ".[$i].body // \"\"" /tmp/linked-issues.json)
156+
issue_repo=$(jq -r ".[$i].repository_url" /tmp/linked-issues.json | grep -oE '[^/]+/[^/]+$')
157+
issue_state=$(jq -r ".[$i].state" /tmp/linked-issues.json)
158+
159+
prompt+=$'\n'
160+
prompt+="### $issue_repo#$issue_number: $issue_title ($issue_state)"
161+
prompt+=$'\n\n'
162+
163+
if [ -n "$issue_body" ] && [ "$issue_body" != "null" ]; then
164+
# Truncate very long issue bodies
165+
truncated=$(echo "$issue_body" | head -c 3000)
166+
prompt+="$truncated"
167+
if [ ${#issue_body} -gt 3000 ]; then
168+
prompt+=$'\n[...truncated]'
169+
fi
170+
prompt+=$'\n'
171+
fi
172+
done
173+
fi
174+
175+
# --- PR diff ---
176+
prompt+=$'\n'
177+
prompt+="## PR diff"
178+
prompt+=$'\n\n'
179+
prompt+='```diff'
180+
prompt+=$'\n'
181+
# Truncate very large diffs
182+
diff_content=$(head -c 50000 /tmp/pr.diff)
183+
prompt+="$diff_content"
184+
diff_size=$(wc -c < /tmp/pr.diff | tr -d ' ')
185+
if [ "$diff_size" -gt 50000 ]; then
186+
prompt+=$'\n[...diff truncated, read the full files for complete context]'
187+
fi
188+
prompt+=$'\n'
189+
prompt+='```'
190+
prompt+=$'\n'
191+
192+
# --- Previous PR comments (conversation) ---
193+
num_pr_comments=$(jq 'length' /tmp/pr-comments.json)
194+
if [ "$num_pr_comments" -gt 0 ]; then
195+
prompt+=$'\n'
196+
prompt+="## Previous PR comments"
197+
prompt+=$'\n'
198+
199+
for i in $(seq 0 $((num_pr_comments - 1))); do
200+
commenter=$(jq -r ".[$i].user.login" /tmp/pr-comments.json)
201+
comment_body=$(jq -r ".[$i].body" /tmp/pr-comments.json)
202+
comment_date=$(jq -r ".[$i].created_at" /tmp/pr-comments.json)
203+
204+
prompt+=$'\n'
205+
prompt+="**$commenter** ($comment_date):"
206+
prompt+=$'\n\n'
207+
prompt+="$comment_body"
208+
prompt+=$'\n'
209+
done
210+
fi
211+
212+
# --- Previous reviews ---
213+
num_all_reviews=$(jq 'length' /tmp/all-reviews.json)
214+
if [ "$num_all_reviews" -gt 0 ]; then
215+
prompt+=$'\n'
216+
prompt+="## Previous reviews"
217+
prompt+=$'\n'
218+
219+
for i in $(seq 0 $((num_all_reviews - 1))); do
220+
rid=$(jq -r ".[$i].id" /tmp/all-reviews.json)
221+
# Skip the current review - it's shown separately below
222+
if [ "$rid" = "${{ inputs.review_id }}" ]; then
223+
continue
224+
fi
225+
226+
rev_reviewer=$(jq -r ".[$i].user.login" /tmp/all-reviews.json)
227+
rev_state=$(jq -r ".[$i].state" /tmp/all-reviews.json)
228+
rev_body=$(jq -r ".[$i].body // \"\"" /tmp/all-reviews.json)
229+
rev_date=$(jq -r ".[$i].submitted_at" /tmp/all-reviews.json)
230+
231+
prompt+=$'\n'
232+
prompt+="### Review by $rev_reviewer ($rev_state, $rev_date)"
233+
prompt+=$'\n\n'
234+
235+
if [ -n "$rev_body" ] && [ "$rev_body" != "null" ] && [ "$rev_body" != "" ]; then
236+
prompt+="$rev_body"
237+
prompt+=$'\n'
238+
fi
239+
done
240+
fi
241+
242+
# --- Current review (the one to address) ---
243+
prompt+=$'\n'
244+
prompt+="## Current review to address"
245+
prompt+=$'\n'
246+
247+
if [ -n "$review_body" ] && [ "$review_body" != "null" ]; then
248+
prompt+=$'\n'
249+
prompt+="### Review body"
250+
prompt+=$'\n\n'
251+
prompt+="$review_body"
252+
prompt+=$'\n'
253+
fi
254+
255+
if [ "$num_comments" -gt 0 ]; then
256+
prompt+=$'\n'
257+
prompt+="### Review comments"
258+
prompt+=$'\n'
259+
260+
for i in $(seq 0 $((num_comments - 1))); do
261+
file_path=$(jq -r ".[$i].path" /tmp/review-comments.json)
262+
line=$(jq -r ".[$i].line // .[$i].original_line // \"?\"" /tmp/review-comments.json)
263+
body=$(jq -r ".[$i].body" /tmp/review-comments.json)
264+
diff_hunk=$(jq -r ".[$i].diff_hunk // \"\"" /tmp/review-comments.json)
265+
266+
prompt+=$'\n'
267+
prompt+="#### $file_path (line $line)"
268+
prompt+=$'\n\n'
269+
270+
if [ -n "$diff_hunk" ] && [ "$diff_hunk" != "null" ]; then
271+
prompt+='```diff'
272+
prompt+=$'\n'
273+
prompt+="$diff_hunk"
274+
prompt+=$'\n'
275+
prompt+='```'
276+
prompt+=$'\n\n'
277+
fi
278+
279+
prompt+="$body"
280+
prompt+=$'\n'
281+
done
282+
fi
283+
284+
prompt+=$'\n'
285+
prompt+="## Instructions"
286+
prompt+=$'\n\n'
287+
prompt+="Please address this review. Make the requested code changes, then run tests with \`make tests\` and static analysis with \`make phpstan\` to verify."
288+
prompt+=$'\n'
289+
prompt+="Commit each logical change separately with a descriptive message."
290+
291+
{
292+
echo 'PROMPT<<PROMPT_DELIMITER'
293+
echo "$prompt"
294+
echo 'PROMPT_DELIMITER'
295+
} >> "$GITHUB_OUTPUT"
296+
297+
- name: Install Claude Code
298+
run: npm install -g @anthropic-ai/claude-code
299+
300+
- name: Run Claude
301+
env:
302+
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
303+
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }}
304+
PROMPT: ${{ steps.prompt.outputs.PROMPT }}
305+
run: |
306+
claude --model claude-opus-4-6 \
307+
--dangerously-skip-permissions \
308+
--output-format text \
309+
-p "$PROMPT"
310+
> /tmp/claude-response.txt
311+
312+
- name: Commit and push changes
313+
id: push
314+
run: |
315+
if [ -z "$(git status --porcelain)" ]; then
316+
echo "pushed=false" >> "$GITHUB_OUTPUT"
317+
exit 0
318+
fi
319+
320+
git config user.name "phpstan-bot"
321+
git config user.email "ondrej+phpstanbot@mirtes.cz"
322+
git add -A
323+
git commit -m "Address review feedback on PR #${{ inputs.pr_number }}"
324+
git push
325+
echo "pushed=true" >> "$GITHUB_OUTPUT"
326+
327+
- name: Reply to review
328+
if: always()
329+
env:
330+
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }}
331+
PUSHED: ${{ steps.push.outputs.pushed }}
332+
run: |
333+
body=""
334+
if [ -f /tmp/claude-response.txt ]; then
335+
body=$(cat /tmp/claude-response.txt)
336+
fi
337+
338+
if [ "$PUSHED" = "true" ]; then
339+
body+=$'\n\n---\n_I pushed a commit addressing this review._'
340+
fi
341+
342+
if [ -z "$body" ]; then
343+
body="I processed this review but have nothing to report."
344+
fi
345+
346+
gh api \
347+
"repos/phpstan/phpstan-src/pulls/${{ inputs.pr_number }}/comments" \
348+
-f body="$body" \
349+
-F in_reply_to="$(jq -r '.[0].id // empty' /tmp/review-comments.json)" \
350+
2>/dev/null \
351+
|| gh pr comment "${{ inputs.pr_number }}" \
352+
--repo "phpstan/phpstan-src" \
353+
--body "$body"

0 commit comments

Comments
 (0)