A GitHub Actions workflow that uses Claude Opus (via AWS Bedrock) to automatically review pull requests with inline comments.
- Two-round review system — Round 1 reviews the full diff, Round 2 only reviews changes made after Round 1 (to address feedback). No further reviews after Round 2.
- Inline PR comments — Feedback is posted as review comments on specific file/line, not just a wall of text.
- Cost tracking — Each review comment includes token usage and estimated cost.
- Smart skipping — Docs/config-only PRs are skipped automatically.
- Large diff handling — Diffs over 150k chars are truncated to fit the context window.
- AWS Bedrock access to Claude Opus (
us.anthropic.claude-opus-4-6-v1inus-east-1) - Repository secrets:
AWS_ACCESS_KEY_ID— IAM user/role withbedrock:InvokeModelpermissionAWS_SECRET_ACCESS_KEY
- Node.js available on the runner (used to build the request JSON and parse the response)
ghCLI available on the runner (used to fetch PR diffs)
In the AWS Console → Bedrock → Model access, request access to Anthropic Claude Opus in us-east-1.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "arn:aws:bedrock:us-east-1::foundation-model/us.anthropic.claude-opus-4-6-v1"
}
]
}Go to Settings → Secrets and variables → Actions, and add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
Create .github/workflows/review.yml with the content below. Then call it from your main CI workflow:
# In your ci.yml
jobs:
review:
if: github.event_name == 'pull_request'
uses: ./.github/workflows/review.yml
with:
pr_number: ${{ github.event.pull_request.number }}
secrets: inheritname: Code Review
on:
workflow_call:
inputs:
pr_number:
description: 'PR number to review'
type: number
required: true
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
type: number
required: true
jobs:
# ── Job 1: Determine review round ────────────────────────────
check-review-status:
name: Review Status
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: read
pull-requests: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
review_round: ${{ steps.check.outputs.review_round }}
steps:
- uses: actions/checkout@v4
- name: Check if review should run
id: check
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = ${{ inputs.pr_number }};
// Skip review for docs/config-only changes
if (context.payload.pull_request) {
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const ignoredPatterns = [/\.md$/, /^docs\//, /^\.github\//, /\.json$/, /\.ya?ml$/];
const hasCodeChanges = files.some(f => !ignoredPatterns.some(p => p.test(f.filename)));
if (!hasCodeChanges) {
console.log('Only docs/config files changed. Skipping review.');
core.setOutput('should_run', 'false');
core.setOutput('review_round', 'skip');
return;
}
}
// Check for previous review round markers in PR comments
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const round1Done = comments.data.some(c =>
c.body.includes('<!-- CLAUDE_REVIEW_ROUND_1 -->')
);
const round2Done = comments.data.some(c =>
c.body.includes('<!-- CLAUDE_REVIEW_ROUND_2_COMPLETE -->')
);
if (round2Done) {
core.setOutput('should_run', 'false');
core.setOutput('review_round', 'complete');
} else if (round1Done) {
core.setOutput('should_run', 'true');
core.setOutput('review_round', '2');
} else {
core.setOutput('should_run', 'true');
core.setOutput('review_round', '1');
}
# ── Job 2: Run Claude review via Bedrock ─────────────────────
claude-review:
name: Claude Review
needs: check-review-status
if: needs.check-review-status.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get PR diff
id: pr
run: |
PR_NUMBER=${{ inputs.pr_number }}
echo "number=$PR_NUMBER" >> $GITHUB_OUTPUT
HEAD_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid -q .headRefOid)
echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT
# Round 2: only changes since first commit. Round 1: full diff.
if [ "${{ needs.check-review-status.outputs.review_round }}" = "2" ]; then
FIRST_COMMIT=$(gh pr view "$PR_NUMBER" --json commits -q '.commits[0].oid')
git diff "$FIRST_COMMIT"..HEAD > pr.diff
else
gh pr diff "$PR_NUMBER" > pr.diff
fi
gh pr view "$PR_NUMBER" --json files -q '.files[].path' > changed_files.txt
# Truncate large diffs to fit context window
DIFF_SIZE=$(wc -c < pr.diff)
if [ "$DIFF_SIZE" -gt 150000 ]; then
head -c 150000 pr.diff > pr.diff.tmp && mv pr.diff.tmp pr.diff
echo "DIFF_TRUNCATED=true" >> $GITHUB_OUTPUT
else
echo "DIFF_TRUNCATED=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Build Bedrock request and call Claude
run: |
# Build request JSON using Node to avoid ARG_MAX limits with large diffs
node << 'SCRIPT'
const fs = require('fs');
const round = process.env.REVIEW_ROUND;
const diffTruncated = process.env.DIFF_TRUNCATED === 'true';
const systemPrompt = `You are a senior software engineer performing a code review.
Analyze the PR diff carefully and provide actionable, specific feedback.
Focus on bugs, security issues, performance problems, and code quality.`;
const diff = fs.readFileSync('pr.diff', 'utf8');
const changedFiles = fs.readFileSync('changed_files.txt', 'utf8').trim();
const parts = [
`Review Round: ${round}`,
'',
'## Files changed in this PR',
'These are the ONLY files modified in this PR. Do NOT reference any other files:',
changedFiles,
'',
diffTruncated ? '> **Note**: Diff was truncated. Review only the code shown.\n' : '',
'## Diff',
'```diff',
diff,
'```',
'',
'Respond with ONLY a valid JSON object (no markdown fences).',
'Use this exact structure:',
'{ "summary": "...", "changes": ["..."], "feedback": [{"file": "...", "line": 42, "comment": "..."}] }',
'',
'Rules:',
'- "file" MUST be from the changed files list above.',
'- "line" MUST reference a line from the NEW version (+ side of diff).',
'- Only flag real issues: bugs, security, performance, significant code quality.',
'- Do NOT include nitpicks, style preferences, or documentation suggestions.',
'- Do NOT hallucinate code not visible in the diff.',
'- Empty feedback array if no issues.',
];
const request = {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 8192,
system: systemPrompt,
messages: [{ role: 'user', content: parts.join('\n') }],
};
fs.writeFileSync('/tmp/bedrock-request.json', JSON.stringify(request));
SCRIPT
# Call Bedrock
aws bedrock-runtime invoke-model \
--model-id us.anthropic.claude-opus-4-6-v1 \
--content-type application/json \
--accept application/json \
--body fileb:///tmp/bedrock-request.json \
/tmp/bedrock-response.json
# Extract response text and usage
node -e "
const fs = require('fs');
const resp = JSON.parse(fs.readFileSync('/tmp/bedrock-response.json', 'utf8'));
fs.writeFileSync('review.json', resp.content[0].text);
fs.writeFileSync('review-usage.json', JSON.stringify(resp.usage || {}));
"
env:
REVIEW_ROUND: ${{ needs.check-review-status.outputs.review_round }}
DIFF_TRUNCATED: ${{ steps.pr.outputs.DIFF_TRUNCATED }}
- name: Post review as PR comments
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const prNumber = ${{ steps.pr.outputs.number }};
const headSha = '${{ steps.pr.outputs.head_sha }}';
const reviewRound = '${{ needs.check-review-status.outputs.review_round }}';
// Token usage and cost
let usage = { input_tokens: 0, output_tokens: 0 };
try { usage = JSON.parse(fs.readFileSync('review-usage.json', 'utf8')); } catch {}
const cost = ((usage.input_tokens / 1e6) * 5 + (usage.output_tokens / 1e6) * 25).toFixed(4);
// Parse Claude's JSON response
let review;
const raw = fs.readFileSync('review.json', 'utf8').trim();
try {
let json = raw;
if (json.startsWith('```')) json = json.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
review = JSON.parse(json);
} catch (e) {
// Fallback: post raw output
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber,
body: `## Claude Review (Round ${reviewRound})\n\n${raw}`
});
return;
}
// Build summary comment with round marker
const isRound1 = reviewRound === '1';
const marker = isRound1
? '<!-- CLAUDE_REVIEW_ROUND_1 -->'
: '<!-- CLAUDE_REVIEW_ROUND_2_COMPLETE -->';
const label = isRound1 ? 'Round 1' : 'Round 2 (Final)';
const feedbackCount = (review.feedback || []).length;
let body = `## Claude Review - ${label}\n\n${marker}\n\n`;
body += `### Summary\n${review.summary}\n\n`;
body += `### Changes\n${(review.changes || []).map(c => `- ${c}`).join('\n')}\n\n---\n`;
body += feedbackCount === 0
? `:white_check_mark: No issues found\n\n`
: `:mag: Found ${feedbackCount} suggestions (see inline comments)\n\n`;
body += `*Claude Opus 4.6 via Bedrock | ${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out | $${cost}*`;
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, body
});
// Post inline comments
for (const item of (review.feedback || [])) {
try {
await github.rest.pulls.createReviewComment({
owner: context.repo.owner, repo: context.repo.repo,
pull_number: prNumber,
body: item.comment,
path: item.file,
line: item.line,
commit_id: headSha,
side: 'RIGHT'
});
} catch (e) {
console.log(`Warning: Failed to post on ${item.file}:${item.line}: ${e.message}`);
}
}PR opened → Round 1 (full diff) → posts marker comment
↓
Developer pushes fixes → Round 2 (only new changes) → posts final marker
↓
Further pushes → no more reviews (Round 2 marker found)
This prevents infinite review loops. The round markers are HTML comments (<!-- CLAUDE_REVIEW_ROUND_1 -->) embedded in the summary comment, invisible to readers but detectable by the workflow.
The system prompt and review rules are defined inline in the "Build Bedrock request" step. To customize:
- Change what gets flagged — Edit the
Rules:section in the prompt - Use a prompt file — Load from a file instead of inline (the workflow supports
~/.config/claude/prompts/pr-review.mdas an optional override) - Adjust token limits — Change
max_tokens: 8192for longer/shorter reviews - Switch models — Replace
us.anthropic.claude-opus-4-6-v1with Sonnet for cheaper reviews on smaller PRs
Typical review costs with Claude Opus:
- Small PR (~500 lines): ~$0.05–0.10
- Medium PR (~2000 lines): ~$0.15–0.30
- Large PR (~5000+ lines): ~$0.40–0.80
The cost is logged in each review comment so you can track spend.
Thanks yon