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
- to bypass the two round limit simply delete the comment that includes "Claude Review - Round 2" on the header and the next push will kick off another round, the inline thread is preserved even after deleting the original comment, combined w/ an empty commit you can trigger a new review at any time:
$ git commit --allow-empty -m "chore: trigger review workflow"
- you'll want to add a specific rule to your CLAUDE.md file telling Claude to first comment, resolve and only then push changes otherwise the workflow will kick in too early and you'll end up with the same comments even after a fix has been applied
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