Claude PR Review #372
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "Claude PR Review" | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: "Pull request number" | |
| required: true | |
| type: number | |
| review_id: | |
| description: "Pull request review ID" | |
| required: true | |
| type: number | |
| workflow_call: | |
| inputs: | |
| pr_number: | |
| description: "Pull request number" | |
| required: true | |
| type: number | |
| review_id: | |
| description: "Pull request review ID" | |
| required: true | |
| type: number | |
| concurrency: | |
| group: claude-pr-review-${{ inputs.pr_number }}-${{ inputs.review_id }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| jobs: | |
| check-reviewer: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| outputs: | |
| skip: ${{ steps.check.outputs.skip }} | |
| steps: | |
| - name: Harden the runner (Audit all outbound calls) | |
| uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 | |
| with: | |
| egress-policy: audit | |
| - name: Check if review is from phpstan-bot | |
| id: check | |
| env: | |
| GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }} | |
| run: | | |
| reviewer=$(gh api "repos/phpstan/phpstan-src/pulls/${{ inputs.pr_number }}/reviews/${{ inputs.review_id }}" --jq '.user.login') | |
| if [ "$reviewer" = "phpstan-bot" ]; then | |
| echo "Skipping review from phpstan-bot" | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| respond: | |
| needs: check-reviewer | |
| if: needs.check-reviewer.outputs.skip != 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Harden the runner (Audit all outbound calls) | |
| uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 | |
| with: | |
| egress-policy: audit | |
| - name: Fetch PR and review details | |
| id: review | |
| env: | |
| GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }} | |
| run: | | |
| PR_NUM="${{ inputs.pr_number }}" | |
| REPO="phpstan/phpstan-src" | |
| # Core PR and review data | |
| gh api "repos/$REPO/pulls/$PR_NUM" > /tmp/pr.json | |
| gh api "repos/$REPO/pulls/$PR_NUM/reviews/${{ inputs.review_id }}" > /tmp/review.json | |
| gh api "repos/$REPO/pulls/$PR_NUM/reviews/${{ inputs.review_id }}/comments" --paginate > /tmp/review-comments.json | |
| # Additional context: all comments, all reviews, and PR diff | |
| gh api "repos/$REPO/issues/$PR_NUM/comments" --paginate > /tmp/pr-comments.json | |
| gh api "repos/$REPO/pulls/$PR_NUM/reviews" --paginate > /tmp/all-reviews.json | |
| gh api "repos/$REPO/pulls/$PR_NUM/comments" --paginate > /tmp/all-review-comments.json | |
| gh api "repos/$REPO/pulls/$PR_NUM" -H "Accept: application/vnd.github.diff" > /tmp/pr.diff | |
| # Fetch linked issues referenced in the PR body | |
| pr_body=$(jq -r '.body // ""' /tmp/pr.json) | |
| echo '[]' > /tmp/linked-issues.json | |
| if [ -n "$pr_body" ]; then | |
| # Match patterns like #1234, phpstan/phpstan#1234, phpstan/phpstan-src#1234, | |
| # and full GitHub issue/PR URLs | |
| 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) | |
| issues_array="[]" | |
| for ref in $issue_refs; do | |
| # Normalize to owner/repo and number | |
| if echo "$ref" | grep -qE '^https://'; then | |
| issue_repo=$(echo "$ref" | grep -oE 'phpstan/(phpstan|phpstan-src)') | |
| issue_num=$(echo "$ref" | grep -oE '[0-9]+$') | |
| elif echo "$ref" | grep -qE '^phpstan/'; then | |
| issue_repo=$(echo "$ref" | cut -d'#' -f1) | |
| issue_num=$(echo "$ref" | cut -d'#' -f2) | |
| else | |
| # Plain #1234 - default to phpstan/phpstan (the issue tracker) | |
| issue_repo="phpstan/phpstan" | |
| issue_num=$(echo "$ref" | tr -d '#') | |
| fi | |
| if issue_json=$(gh api "repos/$issue_repo/issues/$issue_num" 2>/dev/null); then | |
| issues_array=$(echo "$issues_array" | jq --argjson item "$issue_json" '. + [$item]') | |
| fi | |
| done | |
| echo "$issues_array" > /tmp/linked-issues.json | |
| fi | |
| echo "pr_head_ref=$(jq -r '.head.ref' /tmp/pr.json)" >> "$GITHUB_OUTPUT" | |
| - name: "Checkout" | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| ref: ${{ steps.review.outputs.pr_head_ref }} | |
| token: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }} | |
| fetch-depth: 0 | |
| - name: "Install PHP" | |
| uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2 | |
| with: | |
| coverage: "none" | |
| php-version: "8.4" | |
| extensions: mbstring | |
| ini-file: development | |
| ini-values: memory_limit=-1 | |
| - name: "Install dependencies" | |
| uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3 | |
| - name: "Install test dependencies" | |
| run: composer install --working-dir tests | |
| - name: Build prompt | |
| id: prompt | |
| shell: bash | |
| run: | | |
| review_body=$(jq -r '.body // ""' /tmp/review.json) | |
| review_state=$(jq -r '.state' /tmp/review.json) | |
| reviewer=$(jq -r '.user.login' /tmp/review.json) | |
| num_comments=$(jq 'length' /tmp/review-comments.json) | |
| prompt="A reviewer ($reviewer) submitted a review on PR #${{ inputs.pr_number }} with state: $review_state." | |
| prompt+=$'\n' | |
| # --- PR description --- | |
| pr_title=$(jq -r '.title // ""' /tmp/pr.json) | |
| pr_body=$(jq -r '.body // ""' /tmp/pr.json) | |
| pr_author=$(jq -r '.user.login' /tmp/pr.json) | |
| prompt+=$'\n' | |
| prompt+="## Pull request" | |
| prompt+=$'\n\n' | |
| prompt+="**#${{ inputs.pr_number }}: $pr_title** (by $pr_author)" | |
| prompt+=$'\n\n' | |
| if [ -n "$pr_body" ] && [ "$pr_body" != "null" ]; then | |
| prompt+="$pr_body" | |
| prompt+=$'\n' | |
| fi | |
| # --- Linked issues --- | |
| num_issues=$(jq 'length' /tmp/linked-issues.json) | |
| if [ "$num_issues" -gt 0 ]; then | |
| prompt+=$'\n' | |
| prompt+="## Linked issues" | |
| prompt+=$'\n' | |
| for i in $(seq 0 $((num_issues - 1))); do | |
| issue_title=$(jq -r ".[$i].title" /tmp/linked-issues.json) | |
| issue_number=$(jq -r ".[$i].number" /tmp/linked-issues.json) | |
| issue_body=$(jq -r ".[$i].body // \"\"" /tmp/linked-issues.json) | |
| issue_repo=$(jq -r ".[$i].repository_url // \"\"" /tmp/linked-issues.json | grep -oE '[^/]+/[^/]+$' || echo "unknown") | |
| issue_state=$(jq -r ".[$i].state" /tmp/linked-issues.json) | |
| prompt+=$'\n' | |
| prompt+="### $issue_repo#$issue_number: $issue_title ($issue_state)" | |
| prompt+=$'\n\n' | |
| if [ -n "$issue_body" ] && [ "$issue_body" != "null" ]; then | |
| # Truncate very long issue bodies | |
| truncated=$(echo "$issue_body" | head -c 3000) | |
| prompt+="$truncated" | |
| if [ ${#issue_body} -gt 3000 ]; then | |
| prompt+=$'\n[...truncated]' | |
| fi | |
| prompt+=$'\n' | |
| fi | |
| done | |
| fi | |
| # --- PR diff --- | |
| prompt+=$'\n' | |
| prompt+="## PR diff" | |
| prompt+=$'\n\n' | |
| prompt+='```diff' | |
| prompt+=$'\n' | |
| # Truncate very large diffs | |
| diff_content=$(head -c 50000 /tmp/pr.diff) | |
| prompt+="$diff_content" | |
| diff_size=$(wc -c < /tmp/pr.diff | tr -d ' ') | |
| if [ "$diff_size" -gt 50000 ]; then | |
| prompt+=$'\n[...diff truncated, read the full files for complete context]' | |
| fi | |
| prompt+=$'\n' | |
| prompt+='```' | |
| prompt+=$'\n' | |
| # --- Previous PR comments (conversation) --- | |
| num_pr_comments=$(jq 'length' /tmp/pr-comments.json) | |
| if [ "$num_pr_comments" -gt 0 ]; then | |
| prompt+=$'\n' | |
| prompt+="## Previous PR comments" | |
| prompt+=$'\n' | |
| for i in $(seq 0 $((num_pr_comments - 1))); do | |
| commenter=$(jq -r ".[$i].user.login" /tmp/pr-comments.json) | |
| comment_body=$(jq -r ".[$i].body" /tmp/pr-comments.json) | |
| comment_date=$(jq -r ".[$i].created_at" /tmp/pr-comments.json) | |
| prompt+=$'\n' | |
| prompt+="**$commenter** ($comment_date):" | |
| prompt+=$'\n\n' | |
| prompt+="$comment_body" | |
| prompt+=$'\n' | |
| done | |
| fi | |
| # --- Previous reviews --- | |
| num_all_reviews=$(jq 'length' /tmp/all-reviews.json) | |
| if [ "$num_all_reviews" -gt 0 ]; then | |
| prompt+=$'\n' | |
| prompt+="## Previous reviews" | |
| prompt+=$'\n' | |
| for i in $(seq 0 $((num_all_reviews - 1))); do | |
| rid=$(jq -r ".[$i].id" /tmp/all-reviews.json) | |
| # Skip the current review - it's shown separately below | |
| if [ "$rid" = "${{ inputs.review_id }}" ]; then | |
| continue | |
| fi | |
| rev_reviewer=$(jq -r ".[$i].user.login" /tmp/all-reviews.json) | |
| rev_state=$(jq -r ".[$i].state" /tmp/all-reviews.json) | |
| rev_body=$(jq -r ".[$i].body // \"\"" /tmp/all-reviews.json) | |
| rev_date=$(jq -r ".[$i].submitted_at" /tmp/all-reviews.json) | |
| prompt+=$'\n' | |
| prompt+="### Review by $rev_reviewer ($rev_state, $rev_date)" | |
| prompt+=$'\n\n' | |
| if [ -n "$rev_body" ] && [ "$rev_body" != "null" ] && [ "$rev_body" != "" ]; then | |
| prompt+="$rev_body" | |
| prompt+=$'\n' | |
| fi | |
| # Include inline comments for this review | |
| rev_comments=$(jq -r --argjson rid "$rid" '[.[] | select(.pull_request_review_id == $rid)]' /tmp/all-review-comments.json) | |
| num_rev_comments=$(echo "$rev_comments" | jq 'length') | |
| if [ "$num_rev_comments" -gt 0 ]; then | |
| for j in $(seq 0 $((num_rev_comments - 1))); do | |
| rc_path=$(echo "$rev_comments" | jq -r ".[$j].path") | |
| rc_line=$(echo "$rev_comments" | jq -r ".[$j].line // .[$j].original_line // \"?\"") | |
| rc_body=$(echo "$rev_comments" | jq -r ".[$j].body") | |
| rc_diff_hunk=$(echo "$rev_comments" | jq -r ".[$j].diff_hunk // \"\"") | |
| prompt+=$'\n' | |
| prompt+="**$rc_path** (line $rc_line):" | |
| prompt+=$'\n\n' | |
| if [ -n "$rc_diff_hunk" ] && [ "$rc_diff_hunk" != "null" ]; then | |
| prompt+='```diff' | |
| prompt+=$'\n' | |
| prompt+="$rc_diff_hunk" | |
| prompt+=$'\n' | |
| prompt+='```' | |
| prompt+=$'\n\n' | |
| fi | |
| prompt+="$rc_body" | |
| prompt+=$'\n' | |
| done | |
| fi | |
| done | |
| fi | |
| # --- Current review (the one to address) --- | |
| prompt+=$'\n' | |
| prompt+="## Current review to address" | |
| prompt+=$'\n' | |
| if [ -n "$review_body" ] && [ "$review_body" != "null" ]; then | |
| prompt+=$'\n' | |
| prompt+="### Review body" | |
| prompt+=$'\n\n' | |
| prompt+="$review_body" | |
| prompt+=$'\n' | |
| fi | |
| if [ "$num_comments" -gt 0 ]; then | |
| prompt+=$'\n' | |
| prompt+="### Review comments" | |
| prompt+=$'\n' | |
| for i in $(seq 0 $((num_comments - 1))); do | |
| file_path=$(jq -r ".[$i].path" /tmp/review-comments.json) | |
| line=$(jq -r ".[$i].line // .[$i].original_line // \"?\"" /tmp/review-comments.json) | |
| body=$(jq -r ".[$i].body" /tmp/review-comments.json) | |
| diff_hunk=$(jq -r ".[$i].diff_hunk // \"\"" /tmp/review-comments.json) | |
| prompt+=$'\n' | |
| prompt+="#### $file_path (line $line)" | |
| prompt+=$'\n\n' | |
| if [ -n "$diff_hunk" ] && [ "$diff_hunk" != "null" ]; then | |
| prompt+='```diff' | |
| prompt+=$'\n' | |
| prompt+="$diff_hunk" | |
| prompt+=$'\n' | |
| prompt+='```' | |
| prompt+=$'\n\n' | |
| fi | |
| prompt+="$body" | |
| prompt+=$'\n' | |
| done | |
| fi | |
| prompt+=$'\n' | |
| prompt+="## Instructions" | |
| prompt+=$'\n\n' | |
| prompt+="Please address this review. Make the requested code changes, then run tests with \`make tests\` and static analysis with \`make phpstan\` to verify." | |
| prompt+=$'\n' | |
| prompt+="Commit each logical change separately with a descriptive message." | |
| prompt+="Push the commits in the repo." | |
| { | |
| echo 'PROMPT<<PROMPT_DELIMITER' | |
| echo "$prompt" | |
| echo 'PROMPT_DELIMITER' | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Install Claude Code | |
| run: npm install -g @anthropic-ai/claude-code | |
| - name: Run Claude | |
| env: | |
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| GH_TOKEN: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }} | |
| PROMPT: ${{ steps.prompt.outputs.PROMPT }} | |
| run: | | |
| git config user.name "phpstan-bot" | |
| git config user.email "ondrej+phpstanbot@mirtes.cz" | |
| claude --model claude-opus-4-6 \ | |
| --dangerously-skip-permissions \ | |
| --output-format text \ | |
| -p "$PROMPT" \ | |
| > /tmp/claude-response.txt | |
| - name: Reply to review | |
| if: always() | |
| env: | |
| GH_TOKEN: ${{ secrets.PHPSTAN_BOT_PR_TOKEN }} | |
| run: | | |
| body="" | |
| if [ -f /tmp/claude-response.txt ]; then | |
| body=$(cat /tmp/claude-response.txt) | |
| fi | |
| if [ -z "$body" ]; then | |
| body="I processed this review but have nothing to report." | |
| fi | |
| gh api \ | |
| "repos/phpstan/phpstan-src/pulls/${{ inputs.pr_number }}/comments" \ | |
| -f body="$body" \ | |
| -F in_reply_to="$(jq -r '.[0].id // empty' /tmp/review-comments.json)" \ | |
| 2>/dev/null \ | |
| || gh pr comment "${{ inputs.pr_number }}" \ | |
| --repo "phpstan/phpstan-src" \ | |
| --body "$body" |