Created
October 1, 2025 19:51
-
-
Save gvarela/3ee4be6856ba3fa972e53849e5d8a311 to your computer and use it in GitHub Desktop.
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
| # Claude PR Review Workflow | |
| # | |
| # Automatically reviews pull requests using Claude Code AI | |
| # | |
| # TRIGGERS: | |
| # - Initial review: PR opened or marked ready for review | |
| # - Re-review options: | |
| # 1. Add 're-review-requested' label - bypasses claude-reviewed check and allows synchronize events | |
| # 2. Manual trigger via Actions tab (workflow_dispatch) - always runs | |
| # | |
| # SECURITY NOTE: | |
| # - Uses pull_request trigger (not pull_request_target) for safety | |
| # - No untrusted code checkout in privileged context | |
| # - Comment-based triggers removed to prevent security vulnerabilities | |
| # | |
| # SKIP CONDITIONS: | |
| # - Draft PRs | |
| # - PRs with 'skip-claude-review' label or '[skip-claude]' in title | |
| # - WIP PRs (label or title) | |
| # - PRs with 'draft' in title | |
| # | |
| # CONFIGURATION: | |
| # - Timeout: Configurable via CLAUDE_PR_REVIEW_TIMEOUT_MINUTES variable, defaults to 15 | |
| # - Max turns: Configurable via CLAUDE_PR_REVIEW_MAX_TURNS variable, defaults to 20 | |
| # - Default prompt: Configurable via CLAUDE_PR_REVIEW_DEFAULT_PROMPT repository variable, defaults to 'prompt-v1.md' | |
| # - Uses pinned version anthropics/claude-code-action@fd2c17f | |
| # - Review prompt: inline YAML with inline comment support | |
| # - Auto-labels reviewed PRs with 'claude-reviewed' | |
| # - Enables MCP GitHub tools for inline PR comments | |
| # | |
| name: Claude PR Review | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| id-token: write | |
| actions: read | |
| concurrency: | |
| group: claude-review-${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| cancel-in-progress: true | |
| on: | |
| pull_request: | |
| types: [opened, ready_for_review, synchronize, labeled] | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: "PR number to review" | |
| required: true | |
| type: string | |
| jobs: | |
| claude-pr-review: | |
| if: > | |
| github.event_name == 'workflow_dispatch' | |
| || ( | |
| github.event_name == 'pull_request' | |
| && github.event.pull_request.draft == false | |
| && !contains(github.event.pull_request.labels.*.name, 'skip-claude-review') | |
| && !contains(github.event.pull_request.title, '[skip-claude]') | |
| && !contains(github.event.pull_request.labels.*.name, 'wip') | |
| && !contains(github.event.pull_request.title, 'WIP') | |
| && !contains(github.event.pull_request.title, 'draft') | |
| && ( | |
| !contains(github.event.pull_request.labels.*.name, 'claude-reviewed') | |
| || contains(github.event.pull_request.labels.*.name, 're-review-requested') | |
| ) | |
| ) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Get PR details for workflow_dispatch | |
| if: github.event_name == 'workflow_dispatch' | |
| id: pr-details | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: parseInt('${{ github.event.inputs.pr_number }}') | |
| }); | |
| return { | |
| ref: pr.data.head.ref, | |
| sha: pr.data.head.sha, | |
| number: pr.data.number | |
| }; | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 1 | |
| - name: Determine prompt version | |
| id: prompt-version | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const inputPrNumber = '${{ github.event.inputs.pr_number }}'; | |
| const eventName = '${{ github.event_name }}'; | |
| const prNumber = eventName === 'workflow_dispatch' | |
| ? parseInt(inputPrNumber, 10) | |
| : context.payload.pull_request?.number; | |
| console.log(`Event name: ${eventName}`); | |
| console.log(`Input PR number: "${inputPrNumber}"`); | |
| console.log(`Parsed PR number: ${prNumber}`); | |
| // Validate PR number | |
| if (!prNumber || isNaN(prNumber) || prNumber <= 0) { | |
| throw new Error(`Invalid PR number: ${prNumber} (input: "${inputPrNumber}", event: ${eventName})`); | |
| } | |
| console.log(`Processing PR number: ${prNumber}`); | |
| // Get PR details to access labels | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber | |
| }); | |
| const labels = pr.data.labels.map(label => label.name); | |
| console.log(`PR labels: ${labels.join(', ')}`); | |
| // Dynamic prompt file selection based on labels or repository variable | |
| const defaultPrompt = '${{ vars.CLAUDE_PR_REVIEW_DEFAULT_PROMPT }}' || 'prompt-v1.md'; | |
| let promptFile = defaultPrompt; | |
| console.log(`Default prompt from repository variable: ${defaultPrompt}`); | |
| // Find claude-prompt-* labels and convert to filenames | |
| const claudePromptLabel = labels.find(label => label.startsWith('claude-prompt-')); | |
| if (claudePromptLabel) { | |
| // Convert label to filename: claude-prompt-v2 -> prompt-v2.md | |
| const version = claudePromptLabel.replace('claude-prompt-', ''); | |
| // Validate version string: only alphanumeric, hyphens, and underscores allowed | |
| if (/^[a-zA-Z0-9_-]+$/.test(version)) { | |
| const candidateFile = `prompt-${version}.md`; | |
| console.log(`Found label ${claudePromptLabel}, trying file: ${candidateFile}`); | |
| promptFile = candidateFile; | |
| } else { | |
| console.log(`Security warning: Invalid version string '${version}' in label ${claudePromptLabel}, using default`); | |
| } | |
| } | |
| console.log(`Selected prompt file: ${promptFile}`); | |
| return { promptFile }; | |
| - name: Read prompt file | |
| id: read-prompt | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const promptFile = ${{ steps.prompt-version.outputs.result }}.promptFile; | |
| const promptsDir = '.github/workflows/claude-prompts'; | |
| const promptPath = path.join(promptsDir, promptFile); | |
| // Ensure the resolved path is still within the prompts directory | |
| const resolvedPath = path.resolve(promptPath); | |
| const allowedDir = path.resolve(promptsDir); | |
| if (!resolvedPath.startsWith(allowedDir)) { | |
| throw new Error(`Security error: Invalid prompt file path ${promptFile}`); | |
| } | |
| let finalPath = promptPath; | |
| if (!fs.existsSync(promptPath)) { | |
| console.log(`Error: Prompt file ${promptPath} not found, falling back to default`); | |
| finalPath = '.github/workflows/claude-prompts/prompt-v1.md'; | |
| } | |
| console.log(`Using prompt file: ${finalPath}`); | |
| const promptContent = fs.readFileSync(finalPath, 'utf8'); | |
| return { promptContent }; | |
| - name: Run Claude PR Review | |
| continue-on-error: true # Don't fail CI if Claude review fails | |
| uses: anthropics/claude-code-action@14ac8aa20e9b8554d4aacbc5d849a49f734dce63 | |
| with: | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| track_progress: ${{ github.event.action != 'labeled' }} | |
| claude_args: | | |
| --max-turns ${{ vars.CLAUDE_PR_REVIEW_MAX_TURNS || '20' }} | |
| --allowedTools "mcp__github_inline_comment__create_inline_comment,mcp__github__get_pull_request_diff,mcp__github__create_pending_pull_request_review,mcp__github__add_comment_to_pending_review,mcp__github__submit_pending_pull_request_review" | |
| prompt: | | |
| REPO: ${{ github.repository }} | |
| PR NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| COMMIT SHA: ${{ github.event.pull_request.head.sha || github.sha }} | |
| ## Review Instructions | |
| 1. First, use `mcp__github__get_pull_request_diff` to fetch the PR changes | |
| 2. Create a pending review using `mcp__github__create_pending_pull_request_review` | |
| 3. Add inline comments for specific issues using `mcp__github__add_comment_to_pending_review` | |
| 4. Submit the complete review with an overall assessment using `mcp__github__submit_pending_pull_request_review` | |
| --- | |
| ${{ fromJson(steps.read-prompt.outputs.result).promptContent }} | |
| timeout-minutes: ${{ fromJSON(vars.CLAUDE_PR_REVIEW_TIMEOUT_MINUTES || '15') }} | |
| - name: Remove re-review label and add reviewed label | |
| if: success() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const eventName = '${{ github.event_name }}'; | |
| const prNumber = eventName === 'workflow_dispatch' | |
| ? parseInt('${{ github.event.inputs.pr_number }}') | |
| : context.payload.pull_request?.number; | |
| console.log(`Processing PR number: ${prNumber}`); | |
| // Remove re-review label if it exists | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| name: 're-review-requested' | |
| }); | |
| } catch (error) { | |
| // Label doesn't exist, ignore | |
| } | |
| // Add reviewed label | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: ['claude-reviewed'] | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment