Add Claude Code Review Workflow #23
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 Code Review | |
| on: | |
| pull_request: | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| - ready_for_review | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: 'PR number to analyze (optional, for manual runs)' | |
| required: false | |
| type: string | |
| issue_comment: | |
| types: | |
| - created | |
| pull_request_review_comment: | |
| types: | |
| - created | |
| concurrency: | |
| group: claude-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| actions: read | |
| jobs: | |
| claude-review: | |
| name: Claude Code Analysis | |
| if: >- | |
| ( | |
| github.event_name == 'pull_request' | |
| ) || ( | |
| github.event_name == 'issue_comment' && | |
| github.event.comment && | |
| contains(github.event.comment.body, '@claude') | |
| ) || ( | |
| github.event_name == 'pull_request_review_comment' && | |
| github.event.comment && | |
| contains(github.event.comment.body, '@claude') | |
| ) || ( | |
| github.event_name == 'workflow_dispatch' | |
| ) | |
| runs-on: self-hosted | |
| steps: | |
| - name: Post Claude onboarding note | |
| if: ${{ github.event_name == 'pull_request' && github.event.action == 'opened' }} | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const COMMENT_PREFIX = '<img src="https://upload.wikimedia.org/wikipedia/commons/b/b0/Claude_AI_symbol.svg" alt="Claude" width="20" height="20"> Claude\n\n'; | |
| const bodyLines = [ | |
| `${COMMENT_PREFIX}`, | |
| '<!-- claude-review onboarding -->', | |
| '', | |
| '> [!NOTE] To trigger a Claude review, invoke the `@claude review` command.', | |
| '', | |
| '<!-- end claude-review onboarding -->' | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: bodyLines.join('\n') | |
| }); | |
| - name: Determine Claude trigger | |
| id: trigger | |
| run: | | |
| should_run=false | |
| case "$EVENT_NAME" in | |
| issue_comment) | |
| if printf '%s' "$COMMENT_BODY" | grep -iqE '@claude\s+review\b'; then | |
| if [ "$AUTHOR_ASSOCIATION" = "OWNER" ] || [ "$AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$AUTHOR_ASSOCIATION" = "COLLABORATOR" ]; then | |
| if [ "$HAS_PR_OBJECT" = "true" ]; then | |
| echo "✓ Trusted user requested @claude review on PR comment" | |
| should_run=true | |
| else | |
| echo "✗ Comment is on an issue, not a PR. Skipping." | |
| fi | |
| else | |
| echo "✗ Comment author association '$AUTHOR_ASSOCIATION' not permitted" | |
| fi | |
| else | |
| echo "✗ Comment does not contain '@claude review'" | |
| fi | |
| ;; | |
| pull_request_review_comment) | |
| if printf '%s' "$COMMENT_BODY" | grep -iqE '@claude\s+review\b'; then | |
| if [ "$AUTHOR_ASSOCIATION" = "OWNER" ] || [ "$AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$AUTHOR_ASSOCIATION" = "COLLABORATOR" ]; then | |
| echo "✓ Trusted user requested @claude review on PR review comment" | |
| should_run=true | |
| else | |
| echo "✗ Review author association '$AUTHOR_ASSOCIATION' not permitted" | |
| fi | |
| else | |
| echo "✗ PR review comment does not contain '@claude review'" | |
| fi | |
| ;; | |
| workflow_dispatch) | |
| echo "✓ Manual workflow dispatch triggered" | |
| should_run=true | |
| ;; | |
| *) | |
| echo "✗ Event type '$EVENT_NAME' does not require trigger check" | |
| ;; | |
| esac | |
| echo "should_run=$should_run" >> "$GITHUB_OUTPUT" | |
| echo "Final decision: should_run=$should_run" | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| COMMENT_BODY: ${{ github.event.comment.body || '' }} | |
| HAS_PR_OBJECT: ${{ github.event.issue.pull_request != null || github.event.pull_request != null }} | |
| AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association || github.event.pull_request.user.type }} | |
| - name: Detect Claude API key | |
| if: ${{ steps.trigger.outputs.should_run == 'true' }} | |
| id: claude_token | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| run: | | |
| if [ -z "${ANTHROPIC_API_KEY}" ]; then | |
| echo "ANTHROPIC_API_KEY not configured, skipping Claude analysis."; | |
| echo "available=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "available=true" >> "$GITHUB_OUTPUT" | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| fi | |
| - name: Resolve PR context | |
| if: ${{ steps.trigger.outputs.should_run == 'true' }} | |
| id: pr_context | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} | |
| ISSUE_NUMBER: ${{ github.event.issue.number || '' }} | |
| PULL_URL: ${{ github.event.issue.pull_request.url || '' }} | |
| COMMENT_PR_URL: ${{ github.event.comment.pull_request_url || '' }} | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const parseNumberFromUrl = (url) => { | |
| if (!url || typeof url !== 'string') { | |
| return 0; | |
| } | |
| const segments = url.trim().split('/').filter(Boolean); | |
| const last = segments[segments.length - 1]; | |
| const value = Number(last); | |
| return Number.isFinite(value) ? value : 0; | |
| }; | |
| let prNumber = Number(process.env.PR_NUMBER || 0); | |
| const issueNumberEnv = Number(process.env.ISSUE_NUMBER || 0); | |
| if (!prNumber && context.payload.pull_request?.number) { | |
| prNumber = context.payload.pull_request.number; | |
| } | |
| if (!prNumber && context.payload.issue?.number) { | |
| prNumber = context.payload.issue.number; | |
| } | |
| if (!prNumber && issueNumberEnv) { | |
| prNumber = issueNumberEnv; | |
| } | |
| if (!prNumber && context.payload.issue?.pull_request?.url) { | |
| prNumber = parseNumberFromUrl(context.payload.issue.pull_request.url); | |
| } | |
| if (!prNumber && process.env.PULL_URL) { | |
| prNumber = parseNumberFromUrl(process.env.PULL_URL); | |
| } | |
| if (!prNumber && process.env.COMMENT_PR_URL) { | |
| prNumber = parseNumberFromUrl(process.env.COMMENT_PR_URL); | |
| } | |
| if (!prNumber) { | |
| core.info('PR number not resolved; proceeding without PR context.'); | |
| core.setOutput('pr_number', ''); | |
| core.setOutput('head_sha', ''); | |
| core.setOutput('head_ref', ''); | |
| core.setOutput('checkout_ref', ''); | |
| core.setOutput('head_repo', ''); | |
| return; | |
| } | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber | |
| }); | |
| const headSha = pr.head.sha || ''; | |
| const headRef = pr.head.ref || ''; | |
| const checkoutRef = headSha || (headRef ? `refs/heads/${headRef}` : `refs/pull/${prNumber}/head`); | |
| const headRepo = pr.head.repo?.full_name || `${context.repo.owner}/${context.repo.repo}`; | |
| core.setOutput('pr_number', pr.number.toString()); | |
| core.setOutput('head_sha', headSha); | |
| core.setOutput('head_ref', headRef); | |
| core.setOutput('checkout_ref', checkoutRef); | |
| core.setOutput('head_repo', headRepo); | |
| - name: Notify missing Anthropic key | |
| if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'false' }} | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const marker = '<!-- claude-review-setup-reminder -->'; | |
| const docUrl = process.env.CLAUDE_REVIEW_DOC_URL || 'https://wiki.gluzdov.com/doc/claude-review-workflow-setup-Dbg0WdgMsk'; | |
| const COMMENT_PREFIX = '<img src="https://upload.wikimedia.org/wikipedia/commons/b/b0/Claude_AI_symbol.svg" alt="Claude" width="20" height="20"> Claude\n\n'; | |
| const prNumber = Number('${{ steps.pr_context.outputs.pr_number || '' }}'); | |
| if (!prNumber) { | |
| core.info('PR number unavailable; skipping reminder comment.'); | |
| return; | |
| } | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100 | |
| }); | |
| const existing = comments.find(comment => comment.body && comment.body.includes(marker)); | |
| if (!existing) { | |
| const body = `${COMMENT_PREFIX}${marker} | |
| ### Claude Review Setup Required | |
| The Claude review workflow is disabled because \`ANTHROPIC_API_KEY\` is not configured. | |
| Please follow the setup guide: ${docUrl} | |
| `; | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body | |
| }); | |
| } | |
| - name: Checkout repository | |
| if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' }} | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| repository: ${{ steps.pr_context.outputs.head_repo || github.repository }} | |
| ref: ${{ steps.pr_context.outputs.checkout_ref || github.event.pull_request.head.sha || github.event.pull_request.head.ref || github.sha }} | |
| - name: Run Claude Code Action v3.5 Sonnet | |
| if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' }} | |
| id: claude_review | |
| continue-on-error: true | |
| uses: anthropics/claude-code-base-action@e8132bc5e637a42c27763fc757faa37e1ee43b34 | |
| env: | |
| ANTHROPIC_MODEL: claude-3-5-sonnet-latest | |
| with: | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| allowed_tools: | | |
| Bash(git diff --name-only HEAD~1) | |
| Bash(git diff HEAD~1) | |
| View | |
| Glob | |
| Grep | |
| Read | |
| claude_env: | | |
| - DEBUG: true | |
| - LOG_LEVEL: debug | |
| prompt: | | |
| REPO: ${{ github.repository }} | |
| PR NUMBER: ${{ steps.pr_context.outputs.pr_number || github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} | |
| Review only the changed files and diff hunks in this pull request. Focus on defects, security/performance risks, missing tests, or violations of project conventions. | |
| Respond STRICTLY with valid JSON matching this schema (no extra text): | |
| { | |
| "summary": "Concise markdown summary (optional)", | |
| "inline_comments": [ | |
| { | |
| "path": "relative/path/from/repo/root.ext", | |
| "line": <integer line number on the new code>, | |
| "severity": "BUG|SECURITY|PERFORMANCE|SUGGESTION", | |
| "category": "blocking_operation|missing_origin_check|etc", | |
| "rule_id": "RULE_ID", | |
| "summary": "Brief description", | |
| "body": "**[EMOJI] [SEVERITY]: [Brief description]**\n\n[Details]\n\n**Suggestion:**\n```rust\n[fix]\n```\n\n**Why this matters:** [Impact]", | |
| "confidence": 0.9 | |
| } | |
| ] | |
| } | |
| Rules: | |
| - Include an inline comment only when it points to a concrete issue in the diff. Skip praise. | |
| - If you found nothing important, return {"summary": "", "inline_comments": []}. | |
| - For each inline comment, reference the exact changed line so the position is unambiguous. | |
| max_turns: 5 | |
| - name: Process Claude review | |
| if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' && steps.claude_review.outcome == 'success' }} | |
| uses: actions/github-script@v7 | |
| env: | |
| EXECUTION_FILE: ${{ steps.claude_review.outputs.execution_file }} | |
| PR_NUMBER: ${{ steps.pr_context.outputs.pr_number || github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} | |
| HEAD_SHA: ${{ steps.pr_context.outputs.head_sha || github.event.pull_request.head.sha || github.sha }} | |
| OUTPUT_DIR: pr-review-results | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const executionFile = process.env.EXECUTION_FILE; | |
| if (!executionFile || !fs.existsSync(executionFile)) { | |
| core.warning('Claude execution file not found; skipping processing.'); | |
| return; | |
| } | |
| const prNumber = Number(process.env.PR_NUMBER || 0); | |
| if (!prNumber) { | |
| core.warning('PR number unavailable; cannot post review.'); | |
| return; | |
| } | |
| const executionLog = JSON.parse(fs.readFileSync(executionFile, 'utf8')); | |
| const COMMENT_PREFIX = '<img src="https://upload.wikimedia.org/wikipedia/commons/b/b0/Claude_AI_symbol.svg" alt="Claude" width="20" height="20"> Claude\n\n'; | |
| let totalCost = null; | |
| const considerCost = (candidate) => { | |
| if (!candidate || typeof candidate !== 'object') { | |
| return; | |
| } | |
| if (typeof candidate.total_cost_usd === 'number') { | |
| totalCost = candidate.total_cost_usd; | |
| } else if (candidate.result && typeof candidate.result.total_cost_usd === 'number') { | |
| totalCost = candidate.result.total_cost_usd; | |
| } | |
| }; | |
| const visit = (value) => { | |
| if (Array.isArray(value)) { | |
| value.forEach(visit); | |
| } else if (value && typeof value === 'object') { | |
| considerCost(value); | |
| visit(value.message); | |
| visit(value.data); | |
| } | |
| }; | |
| visit(executionLog); | |
| const entries = Array.isArray(executionLog) ? executionLog : [executionLog]; | |
| const assistantEntry = [...entries].reverse().find((entry) => entry && entry.type === 'assistant' && Array.isArray(entry.message?.content) && entry.message.content.length); | |
| if (!assistantEntry) { | |
| core.info('No assistant response found to extract review from.'); | |
| return; | |
| } | |
| const textPayload = assistantEntry.message.content | |
| .filter((part) => part && part.type === 'text' && typeof part.text === 'string') | |
| .map((part) => part.text) | |
| .join('\n') | |
| .trim(); | |
| if (!textPayload) { | |
| core.info('Assistant response did not include textual content to parse.'); | |
| return; | |
| } | |
| const tryParseJson = (input) => { | |
| try { | |
| return JSON.parse(input); | |
| } catch (error) { | |
| return null; | |
| } | |
| }; | |
| let reviewPayload = tryParseJson(textPayload); | |
| if (!reviewPayload) { | |
| const jsonBlockMatch = textPayload.match(/```json\s*([\s\S]*?)```/i) || textPayload.match(/```\s*([\s\S]*?)```/); | |
| if (jsonBlockMatch && jsonBlockMatch[1]) { | |
| reviewPayload = tryParseJson(jsonBlockMatch[1].trim()); | |
| } | |
| } | |
| if (!reviewPayload) { | |
| const firstBrace = textPayload.indexOf('{'); | |
| const lastBrace = textPayload.lastIndexOf('}'); | |
| if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { | |
| reviewPayload = tryParseJson(textPayload.slice(firstBrace, lastBrace + 1)); | |
| } | |
| } | |
| if (!reviewPayload || typeof reviewPayload !== 'object') { | |
| core.warning('Claude response did not include a parsable JSON review payload.'); | |
| return; | |
| } | |
| const summary = typeof reviewPayload.summary === 'string' ? reviewPayload.summary.trim() : ''; | |
| const inlineComments = Array.isArray(reviewPayload.inline_comments) ? reviewPayload.inline_comments : []; | |
| const comments = inlineComments | |
| .map((comment) => { | |
| if (!comment || typeof comment.path !== 'string' || typeof comment.line !== 'number' || !comment.body) { | |
| return null; | |
| } | |
| const body = String(comment.body).trim(); | |
| if (!body) { | |
| return null; | |
| } | |
| return { | |
| path: comment.path, | |
| line: comment.line, | |
| side: 'RIGHT', | |
| body: `${COMMENT_PREFIX}${body}` | |
| }; | |
| }) | |
| .filter(Boolean); | |
| if (comments.length === 0 && !summary && totalCost === null) { | |
| core.info('Claude returned no actionable feedback.'); | |
| return; | |
| } | |
| const costLine = typeof totalCost === 'number' ? `Estimated cost: $${totalCost.toFixed(5)}` : ''; | |
| let reviewBody = ''; | |
| if (summary || costLine) { | |
| const bodySegments = ['## Claude Code Review']; | |
| if (summary) { | |
| bodySegments.push('', summary); | |
| } | |
| if (costLine) { | |
| bodySegments.push('', `*${costLine}*`); | |
| } | |
| reviewBody = `${COMMENT_PREFIX}${bodySegments.join('\n')}`; | |
| } | |
| let headSha = process.env.HEAD_SHA || ''; | |
| if (!headSha) { | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber | |
| }); | |
| headSha = pr.head.sha; | |
| } | |
| await github.rest.pulls.createReview({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| commit_id: headSha, | |
| event: 'COMMENT', | |
| body: reviewBody, | |
| comments | |
| }); | |
| const resultsDir = process.env.OUTPUT_DIR || 'pr-review-results'; | |
| fs.mkdirSync(resultsDir, { recursive: true }); | |
| const resultPayload = { | |
| service: 'claude', | |
| status: 'success', | |
| cost_usd: totalCost || 0, | |
| stats: { | |
| comments: comments.length, | |
| summary: Boolean(summary) | |
| }, | |
| timestamp: new Date().toISOString() | |
| }; | |
| fs.writeFileSync(path.join(resultsDir, 'claude.json'), JSON.stringify(resultPayload, null, 2)); | |
| const summaryLines = ['### Claude Code Review', '', `- Comments posted: ${comments.length}`, `- Estimated cost: $${(totalCost || 0).toFixed(5)}`]; | |
| if (summary) { | |
| summaryLines.push('', summary); | |
| } | |
| fs.writeFileSync(path.join(resultsDir, 'claude-summary.md'), summaryLines.join('\n')); | |
| await core.summary | |
| .addHeading('Claude Code Cost Summary') | |
| .addList([ | |
| 'Outcome: success', | |
| `Estimated cost: $${(totalCost || 0).toFixed(5)}` | |
| ]) | |
| .write(); | |
| const marker = '<!-- claude-cost-summary -->'; | |
| const stickyLines = [ | |
| marker, | |
| '### Claude Code Cost Summary', | |
| '', | |
| '- Outcome: success', | |
| `- Estimated cost: $${(totalCost || 0).toFixed(5)}`, | |
| '' | |
| ]; | |
| const stickyBody = `${COMMENT_PREFIX}${stickyLines.join('\n')}`; | |
| const existingComments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100 | |
| }); | |
| const existing = [...existingComments].reverse().find((comment) => comment.body && comment.body.includes(marker)); | |
| if (existing) { | |
| if (existing.body.trim() !== stickyBody.trim()) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body: stickyBody | |
| }); | |
| } else { | |
| core.info('Claude cost summary already up to date.'); | |
| } | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: stickyBody | |
| }); | |
| } | |
| - name: Upload Claude execution log | |
| if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' && steps.claude_review.outputs.execution_file != '' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: pr-review-claude-execution-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }} | |
| path: ${{ steps.claude_review.outputs.execution_file }} | |
| retention-days: ${{ vars.PR_REVIEW_ARTIFACT_RETENTION_DAYS || 7 }} | |
| if-no-files-found: ignore |