Skip to content

Instantly share code, notes, and snippets.

@rlueder
Created March 9, 2026 20:06
Show Gist options
  • Select an option

  • Save rlueder/a3e7b1eb40d90c29f587a4a8cb7c5a70 to your computer and use it in GitHub Desktop.

Select an option

Save rlueder/a3e7b1eb40d90c29f587a4a8cb7c5a70 to your computer and use it in GitHub Desktop.
Automated PR Reviews with Claude Opus on AWS Bedrock — GitHub Actions workflow

Automated PR Reviews with Claude Opus on AWS Bedrock

A GitHub Actions workflow that uses Claude Opus (via AWS Bedrock) to automatically review pull requests with inline comments.

Features

  • 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.

Prerequisites

  1. AWS Bedrock access to Claude Opus (us.anthropic.claude-opus-4-6-v1 in us-east-1)
  2. Repository secrets:
    • AWS_ACCESS_KEY_ID — IAM user/role with bedrock:InvokeModel permission
    • AWS_SECRET_ACCESS_KEY
  3. Node.js available on the runner (used to build the request JSON and parse the response)
  4. gh CLI available on the runner (used to fetch PR diffs)

Setup

1. Enable Claude Opus in Bedrock

In the AWS Console → Bedrock → Model access, request access to Anthropic Claude Opus in us-east-1.

2. Create an IAM policy

{
  "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"
    }
  ]
}

3. Add secrets to your repository

Go to Settings → Secrets and variables → Actions, and add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.

4. Add the workflow

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: inherit

The Workflow

name: 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}`);
              }
            }

How the Two-Round System Works

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.

Customizing the Prompt

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.md as an optional override)
  • Adjust token limits — Change max_tokens: 8192 for longer/shorter reviews
  • Switch models — Replace us.anthropic.claude-opus-4-6-v1 with Sonnet for cheaper reviews on smaller PRs

Cost

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.

@ruslanchema12-beep
Copy link

Thanks yon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment