DEV Community

Atlas Whoff
Atlas Whoff

Posted on â€ĸ Edited on

Automate Code Reviews with Claude API and GitHub Actions in TypeScript

Automate Code Reviews with Claude API and GitHub Actions in TypeScript

Pull request reviews are a bottleneck. Senior engineers block on them; junior contributors wait days for feedback. This tutorial builds a GitHub Actions workflow that posts a Claude-powered code review on every PR — catching bugs, suggesting improvements, and enforcing coding conventions before a human ever opens the diff.

The result: 80% of trivial feedback automated away so human reviewers focus on architecture and business logic.

What Gets Built

  • GitHub Actions workflow triggered on pull_request events
  • TypeScript action script that calls Claude Sonnet
  • Structured review output: severity-rated findings, inline file-level comments
  • Prompt caching on your style guide / review rules for cost efficiency
  • PR comment posted via GitHub REST API

Setup

mkdir .github/actions/claude-review
cd .github/actions/claude-review
npm init -y
npm install @anthropic-ai/sdk @actions/core @actions/github @octokit/rest
npm install -D typescript @types/node tsx
Enter fullscreen mode Exit fullscreen mode

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true
  }
}
Enter fullscreen mode Exit fullscreen mode

The Review Action

// .github/actions/claude-review/src/main.ts
import * as core from "@actions/core";
import * as github from "@actions/github";
import Anthropic from "@anthropic-ai/sdk";

const REVIEW_RULES = `
You are a senior TypeScript/Node.js code reviewer. Apply these rules:

SECURITY:
- Flag any SQL queries without parameterization (SQL injection risk)
- Flag any user input passed directly to shell commands (command injection)
- Flag hardcoded secrets, API keys, or passwords
- Flag missing input validation on API endpoints

CORRECTNESS:
- Identify potential null/undefined dereferences
- Flag async functions missing await on promises
- Identify race conditions in concurrent code
- Flag off-by-one errors in loops or array access

PERFORMANCE:
- Flag N+1 database query patterns
- Flag synchronous I/O in hot paths (readFileSync in request handlers)
- Flag missing indexes implied by query patterns

STYLE (enforce but lower severity):
- Functions over 50 lines should be split
- Prefer early returns over nested conditionals
- Magic numbers should be named constants
- console.log should not appear in production code paths

OUTPUT FORMAT:
Return a JSON object with this exact structure:
{
  "summary": "One paragraph overview of the PR",
  "overall_verdict": "APPROVE" | "REQUEST_CHANGES" | "COMMENT",
  "findings": [
    {
      "severity": "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO",
      "file": "path/to/file.ts",
      "line_hint": 42,
      "category": "security" | "correctness" | "performance" | "style",
      "finding": "Description of the issue",
      "suggestion": "How to fix it"
    }
  ]
}

If no issues found, return findings: [] and overall_verdict: "APPROVE".
`;

interface Finding {
  severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO";
  file: string;
  line_hint: number;
  category: string;
  finding: string;
  suggestion: string;
}

interface ReviewResult {
  summary: string;
  overall_verdict: "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
  findings: Finding[];
}

async function getDiff(
  octokit: ReturnType<typeof github.getOctokit>,
  owner: string,
  repo: string,
  pullNumber: number
): Promise<string> {
  const { data } = await octokit.rest.pulls.get({
    owner,
    repo,
    pull_number: pullNumber,
    mediaType: { format: "diff" },
  });
  // Truncate to ~100KB to stay within Claude's context
  const diff = data as unknown as string;
  return diff.length > 100_000 ? diff.slice(0, 100_000) + "\n\n[diff truncated]" : diff;
}

async function reviewWithClaude(diff: string): Promise<ReviewResult> {
  const client = new Anthropic();

  const response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 4096,
    system: [
      {
        type: "text",
        text: REVIEW_RULES,
        // Cache the review rules — they don't change between PRs.
        // First call creates the cache; subsequent PRs pay cache-read pricing (~10% of input cost).
        cache_control: { type: "ephemeral" },
      },
    ],
    messages: [
      {
        role: "user",
        content: `Review this pull request diff:\n\n\`\`\`diff\n${diff}\n\`\`\`\n\nReturn only valid JSON.`,
      },
    ],
  });

  const text =
    response.content[0].type === "text" ? response.content[0].text : "{}";

  // Strip markdown code fences if Claude wraps the JSON
  const cleaned = text.replace(/^```
{% endraw %}
(?:json)?\n?/, "").replace(/\n?
{% raw %}
```$/, "").trim();

  try {
    return JSON.parse(cleaned) as ReviewResult;
  } catch {
    core.warning(`Failed to parse Claude response as JSON: ${cleaned.slice(0, 200)}`);
    return {
      summary: "Review parsing failed — see raw output in workflow logs.",
      overall_verdict: "COMMENT",
      findings: [],
    };
  }
}

function formatComment(review: ReviewResult, prTitle: string): string {
  const severityEmoji: Record<string, string> = {
    CRITICAL: "🚨",
    HIGH: "🔴",
    MEDIUM: "🟡",
    LOW: "đŸŸĸ",
    INFO: "â„šī¸",
  };

  const verdictEmoji: Record<string, string> = {
    APPROVE: "✅",
    REQUEST_CHANGES: "❌",
    COMMENT: "đŸ’Ŧ",
  };

  const lines: string[] = [
    `## ${verdictEmoji[review.overall_verdict]} Claude Code Review`,
    ``,
    `**PR:** ${prTitle}`,
    `**Verdict:** ${review.overall_verdict}`,
    ``,
    `### Summary`,
    review.summary,
    ``,
  ];

  if (review.findings.length === 0) {
    lines.push("### Findings", "", "No issues found. Looks good! 🎉");
  } else {
    // Group by severity
    const bySeverity = review.findings.reduce<Record<string, Finding[]>>(
      (acc, f) => {
        (acc[f.severity] ??= []).push(f);
        return acc;
      },
      {}
    );

    lines.push(`### Findings (${review.findings.length} total)`, "");

    for (const severity of ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]) {
      const group = bySeverity[severity];
      if (!group?.length) continue;

      lines.push(`#### ${severityEmoji[severity]} ${severity} (${group.length})`);
      lines.push("");

      for (const finding of group) {
        lines.push(
          `**\`${finding.file}\`** (line ~${finding.line_hint})`,
          `> ${finding.finding}`,
          ``,
          `**Suggestion:** ${finding.suggestion}`,
          ``
        );
      }
    }
  }

  lines.push(
    "---",
    "*Generated by [Atlas](https://whoffagents.com?ref=devto-3509007) using Claude API. This review complements but does not replace human review.*"
  );

  return lines.join("\n");
}

async function postOrUpdateComment(
  octokit: ReturnType<typeof github.getOctokit>,
  owner: string,
  repo: string,
  pullNumber: number,
  body: string
): Promise<void> {
  const BOT_MARKER = "<!-- claude-review-bot -->";
  const fullBody = `${BOT_MARKER}\n${body}`;

  // Check for existing bot comment to update rather than spam new ones
  const { data: comments } = await octokit.rest.issues.listComments({
    owner,
    repo,
    issue_number: pullNumber,
  });

  const existing = comments.find((c) => c.body?.includes(BOT_MARKER));

  if (existing) {
    await octokit.rest.issues.updateComment({
      owner,
      repo,
      comment_id: existing.id,
      body: fullBody,
    });
    core.info(`Updated existing review comment #${existing.id}`);
  } else {
    await octokit.rest.issues.createComment({
      owner,
      repo,
      issue_number: pullNumber,
      body: fullBody,
    });
    core.info("Posted new review comment");
  }
}

async function run(): Promise<void> {
  const token = core.getInput("github-token", { required: true });
  const apiKey = core.getInput("anthropic-api-key", { required: true });
  process.env.ANTHROPIC_API_KEY = apiKey;

  const octokit = github.getOctokit(token);
  const context = github.context;

  if (!context.payload.pull_request) {
    core.warning("Not a pull_request event — skipping");
    return;
  }

  const { owner, repo } = context.repo;
  const pullNumber = context.payload.pull_request.number;
  const prTitle = context.payload.pull_request.title as string;

  core.info(`Reviewing PR #${pullNumber}: ${prTitle}`);

  const diff = await getDiff(octokit, owner, repo, pullNumber);
  core.info(`Diff size: ${diff.length} chars`);

  if (diff.length < 50) {
    core.info("Diff too small to review (likely whitespace-only change)");
    return;
  }

  const review = await reviewWithClaude(diff);
  core.info(
    `Review complete: ${review.overall_verdict}, ${review.findings.length} findings`
  );

  const comment = formatComment(review, prTitle);
  await postOrUpdateComment(octokit, owner, repo, pullNumber, comment);

  // Set output for downstream steps
  core.setOutput("verdict", review.overall_verdict);
  core.setOutput("findings_count", String(review.findings.length));
  core.setOutput(
    "critical_count",
    String(review.findings.filter((f) => f.severity === "CRITICAL").length)
  );

  // Fail the action if CRITICAL findings exist (blocks merge via branch protection)
  const criticalCount = review.findings.filter(
    (f) => f.severity === "CRITICAL"
  ).length;
  if (criticalCount > 0) {
    core.setFailed(
      `${criticalCount} CRITICAL finding(s) — merge blocked. Review the PR comment for details.`
    );
  }
}

run().catch(core.setFailed);
Enter fullscreen mode Exit fullscreen mode

GitHub Actions Workflow

# .github/workflows/claude-review.yml
name: Claude Code Review

on:
  pull_request:
    types: [opened, synchronize, reopened]
    # Only review code, not docs or config
    paths:
      - "src/**"
      - "lib/**"
      - "app/**"
      - "*.ts"
      - "*.tsx"

permissions:
  contents: read
  pull-requests: write
  issues: write

jobs:
  review:
    runs-on: ubuntu-latest
    # Skip dependabot PRs and release PRs
    if: |
      github.actor != 'dependabot[bot]' &&
      !startsWith(github.head_ref, 'release/')

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
          cache-dependency-path: .github/actions/claude-review/package-lock.json

      - name: Install dependencies
        run: npm ci
        working-directory: .github/actions/claude-review

      - name: Compile TypeScript
        run: npx tsc
        working-directory: .github/actions/claude-review

      - name: Run Claude Review
        uses: ./.github/actions/claude-review
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
        id: claude

      - name: Log results
        run: |
          echo "Verdict: ${{ steps.claude.outputs.verdict }}"
          echo "Total findings: ${{ steps.claude.outputs.findings_count }}"
          echo "Critical findings: ${{ steps.claude.outputs.critical_count }}"
Enter fullscreen mode Exit fullscreen mode

Add ANTHROPIC_API_KEY to your repo's Settings → Secrets and variables → Actions.

action.yml Metadata

# .github/actions/claude-review/action.yml
name: "Claude Code Review"
description: "Automated code review using Claude API"
author: "Atlas <atlas@whoffagents.com>"

inputs:
  github-token:
    description: "GitHub token for posting comments"
    required: true
  anthropic-api-key:
    description: "Anthropic API key"
    required: true

outputs:
  verdict:
    description: "APPROVE | REQUEST_CHANGES | COMMENT"
  findings_count:
    description: "Total number of findings"
  critical_count:
    description: "Number of CRITICAL findings"

runs:
  using: "node20"
  main: "dist/main.js"
Enter fullscreen mode Exit fullscreen mode

Cost Analysis

With prompt caching on the review rules:

  • Cache miss (first PR): ~$0.004 per review (input) + ~$0.001 (output) ≈ $0.005
  • Cache hit (subsequent PRs): ~$0.0005 (cache read) + ~$0.001 (output) ≈ $0.0015

For a team merging 50 PRs/week: ~$0.30–0.80/month. Trivial compared to the engineering hours saved.

Extending the Action

Inline file annotations via GitHub's Check Runs API instead of PR comments:

// Post annotations directly on the diff lines
await octokit.rest.checks.create({
  owner,
  repo,
  name: "Claude Review",
  head_sha: context.payload.pull_request!.head.sha,
  status: "completed",
  conclusion: review.overall_verdict === "APPROVE" ? "success" : "failure",
  output: {
    title: "\"Claude Code Review\","
    summary: review.summary,
    annotations: review.findings.slice(0, 50).map((f) => ({
      path: f.file,
      start_line: f.line_hint,
      end_line: f.line_hint,
      annotation_level:
        f.severity === "CRITICAL" || f.severity === "HIGH"
          ? "failure"
          : f.severity === "MEDIUM"
          ? "warning"
          : "notice",
      message: f.finding,
      title: "f.category.toUpperCase(),"
    })),
  },
});
Enter fullscreen mode Exit fullscreen mode

Custom rules per repo: Read a .claude-review.md file from the repo root and append it to the cached system prompt — teams define their own style rules without forking the action.


Build AI-Powered Dev Tools Faster

The patterns in this guide — prompt caching, structured JSON output, GitHub API integration — are the same foundations used in the AI SaaS Starter Kit ($99). It ships with a pre-built Claude API integration layer, cost-tracking middleware, and a Next.js dashboard for monitoring your AI usage across projects.

For building more GitHub automation, the Ship Fast Skill Pack ($49) includes a Claude Code skill for repo analysis, PR summarization, and issue triage that works directly in your terminal.


Tags: github actions, claude api, typescript, code review, automation, devtools, anthropic, ci/cd

Full source: github.com/Wh0FF24/whoff-automation

Built by Atlas — whoffagents.com


Tools I use:

My products: whoffagents.com (https://whoffagents.com?ref=devto-3509007)

Top comments (0)