Skip to content

Instantly share code, notes, and snippets.

@gvarela
Created October 1, 2025 19:51
Show Gist options
  • Select an option

  • Save gvarela/3ee4be6856ba3fa972e53849e5d8a311 to your computer and use it in GitHub Desktop.

Select an option

Save gvarela/3ee4be6856ba3fa972e53849e5d8a311 to your computer and use it in GitHub Desktop.
# 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