๐ŸŽ‰ DevOps Interview Prep Bundle is live โ€” 1000+ Q&A across 20 topicsGet it โ†’
All Articles

Build an AI Code Review Bot with GitHub Actions and Claude API (2026)

Automate code reviews on every PR using Claude AI via GitHub Actions. The bot reviews Dockerfile security, Terraform changes, and general code quality โ€” and posts inline comments.

DevOpsBoysMay 2, 20265 min read
Share:Tweet

Build a GitHub Actions bot that automatically reviews PRs using Claude API โ€” comments on security issues in Dockerfiles, risky Terraform changes, and general code quality. No external service needed.


What We're Building

Every PR triggers a workflow that:

  1. Gets the diff from the PR
  2. Sends it to Claude API for review
  3. Posts review comments inline on the PR via GitHub API

Step 1: Set Up API Keys as Secrets

bash
# In your GitHub repo: Settings โ†’ Secrets โ†’ Actions
# Add:
ANTHROPIC_API_KEY = sk-ant-...

Step 2: Create the Review Script

python
# .github/scripts/ai_review.py
import os
import sys
import json
import anthropic
import requests
 
def get_pr_diff():
    """Get the diff for this PR from GitHub API."""
    repo = os.environ["GITHUB_REPOSITORY"]
    pr_number = os.environ["PR_NUMBER"]
    token = os.environ["GITHUB_TOKEN"]
 
    url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files"
    headers = {
        "Authorization": f"token {token}",
        "Accept": "application/vnd.github.v3+json",
    }
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    return response.json()
 
def review_with_claude(diff_content: str, filename: str) -> str:
    """Send diff to Claude for review."""
    client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
 
    system_prompt = """You are an expert DevOps engineer reviewing code changes.
Focus on:
- Security vulnerabilities (hardcoded secrets, dangerous permissions, exposed ports)
- Dockerfile best practices (non-root user, multi-stage builds, specific image versions)
- Terraform risks (resource deletions, IAM over-permissions, state issues)
- Kubernetes security (privileged pods, missing resource limits, RBAC issues)
- CI/CD pipeline security (secret exposure, injection risks)
 
Be concise. Only comment on real issues โ€” don't be pedantic about style.
Format your response as JSON:
{
  "issues": [
    {
      "severity": "critical|warning|info",
      "line": <line number in the diff if applicable, otherwise null>,
      "message": "short description of the issue",
      "suggestion": "how to fix it"
    }
  ],
  "summary": "one sentence overall assessment"
}
If no issues found, return {"issues": [], "summary": "LGTM - no issues found"}"""
 
    message = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=system_prompt,
        messages=[
            {
                "role": "user",
                "content": f"Review this change to `{filename}`:\n\n```diff\n{diff_content}\n```"
            }
        ]
    )
 
    return message.content[0].text
 
def post_pr_comment(body: str):
    """Post a comment on the PR."""
    repo = os.environ["GITHUB_REPOSITORY"]
    pr_number = os.environ["PR_NUMBER"]
    token = os.environ["GITHUB_TOKEN"]
 
    url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
    headers = {
        "Authorization": f"token {token}",
        "Accept": "application/vnd.github.v3+json",
    }
    data = {"body": body}
    response = requests.post(url, headers=headers, json=data)
    response.raise_for_status()
 
def main():
    files = get_pr_diff()
    
    # Only review relevant file types
    review_extensions = {
        ".tf", ".tfvars",      # Terraform
        "Dockerfile",           # Docker
        ".yml", ".yaml",        # YAML (K8s, CI/CD)
        ".py", ".go", ".js",    # Code files
        ".sh",                  # Shell scripts
    }
 
    all_issues = []
    reviewed_files = []
 
    for file in files:
        filename = file["filename"]
        patch = file.get("patch", "")
 
        # Check if we should review this file
        should_review = any(
            filename.endswith(ext) or filename == ext.lstrip(".")
            for ext in review_extensions
        ) or "Dockerfile" in filename
 
        if not should_review or not patch or len(patch) > 4000:
            continue
 
        print(f"Reviewing {filename}...")
        
        try:
            result_json = review_with_claude(patch, filename)
            result = json.loads(result_json)
            
            if result["issues"]:
                reviewed_files.append(filename)
                for issue in result["issues"]:
                    all_issues.append({
                        "file": filename,
                        **issue
                    })
        except Exception as e:
            print(f"Error reviewing {filename}: {e}")
            continue
 
    # Build the comment
    if not all_issues:
        comment = "## ๐Ÿค– AI Code Review\n\nโœ… **No issues found** โ€” LGTM!\n\n*Reviewed by Claude AI*"
    else:
        critical = [i for i in all_issues if i["severity"] == "critical"]
        warnings = [i for i in all_issues if i["severity"] == "warning"]
        infos = [i for i in all_issues if i["severity"] == "info"]
 
        lines = ["## ๐Ÿค– AI Code Review\n"]
 
        if critical:
            lines.append(f"### ๐Ÿšจ Critical Issues ({len(critical)})\n")
            for issue in critical:
                lines.append(f"**`{issue['file']}`**: {issue['message']}")
                lines.append(f"> ๐Ÿ’ก {issue['suggestion']}\n")
 
        if warnings:
            lines.append(f"### โš ๏ธ Warnings ({len(warnings)})\n")
            for issue in warnings:
                lines.append(f"**`{issue['file']}`**: {issue['message']}")
                lines.append(f"> ๐Ÿ’ก {issue['suggestion']}\n")
 
        if infos:
            lines.append(f"### โ„น๏ธ Suggestions ({len(infos)})\n")
            for issue in infos:
                lines.append(f"**`{issue['file']}`**: {issue['message']}\n")
 
        lines.append(f"\n*Reviewed by Claude AI ยท {len(reviewed_files)} files checked*")
        comment = "\n".join(lines)
 
    post_pr_comment(comment)
    
    # Fail the action if there are critical issues
    if any(i["severity"] == "critical" for i in all_issues):
        print("Critical issues found โ€” failing the check")
        sys.exit(1)
 
if __name__ == "__main__":
    main()

Step 3: GitHub Actions Workflow

yaml
# .github/workflows/ai-review.yml
name: AI Code Review
 
on:
  pull_request:
    types: [opened, synchronize]
 
jobs:
  ai-review:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write  # needed to post comments
      contents: read
 
    steps:
    - uses: actions/checkout@v4
 
    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: "3.12"
 
    - name: Install dependencies
      run: pip install anthropic requests
 
    - name: Run AI review
      env:
        ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        PR_NUMBER: ${{ github.event.pull_request.number }}
      run: python .github/scripts/ai_review.py

Step 4: Try It Out

Open a PR with a file like this:

dockerfile
# Bad Dockerfile (should trigger review)
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3
COPY . .
ENV AWS_SECRET_KEY=mysecretkey123  # hardcoded secret!
RUN pip install -r requirements.txt
CMD ["python3", "app.py"]

The bot should comment with something like:

๐Ÿšจ Critical Issues

Dockerfile: Hardcoded AWS secret key in ENV instruction

๐Ÿ’ก Remove the secret from the Dockerfile. Use ARG for build-time secrets or inject via environment variables at runtime. Never commit credentials to source control.


Cost Estimate

Claude Sonnet API pricing (as of 2026):

  • ~$3 per million input tokens, ~$15 per million output tokens
  • Average PR diff review: ~500 input tokens, ~300 output tokens
  • Cost per PR review: ~$0.002 (less than half a cent)

For a team with 100 PRs/month: ~$0.20/month. Essentially free.


Enhancements

Skip review for bots:

yaml
if: github.actor != 'dependabot[bot]' && !contains(github.actor, 'bot')

Cache reviews so re-runs don't re-review unchanged files:

python
# Check if file changed since last review by comparing SHA
file_sha = file["sha"]

Add specific rules for your stack:

python
# In the system prompt, add company-specific rules
"""Additional company rules:
- All Kubernetes deployments must have resource limits
- All Terraform resources must have a 'team' tag
- No direct secrets in GitHub Actions env vars (use secrets context)"""
Newsletter

Stay ahead of the curve

Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam โ€” just practical engineering content.

Related Articles

Comments