All articles
Build Guides

Deploy a Free GitHub PR Review Bot Using Cloudflare Workers AI

FDE Coach EditorialAugust 26, 202611 min read

What We're Building

A GitHub bot that fires on every pull request, pipes the diff through Llama 3 8B running on Cloudflare Workers AI, and posts inline review comments—bugs, style nits, logic concerns—directly on the PR. Total recurring cost: $0. No GPU rental, no AWS bill, no OpenAI API key.

Feature checklist:

  • Triggers on pull_request.opened and pull_request.synchronize webhooks
  • Sends the unified diff to Llama 3 8B with a structured prompt
  • Parses the LLM response into file/line/comment tuples
  • Posts review comments via the GitHub API
  • Runs entirely within Cloudflare's free tier (100k requests/day, 10ms CPU per request for Workers AI)
  • Single wrangler deploy from your terminal

Architecture Overview

The Worker sits at a public URL. GitHub pings it on PR events. The Worker fetches the diff, constructs a prompt instructing Llama 3 to return a specific JSON schema, calls Workers AI, parses the output, and uses a GitHub App installation token to post a review. No database, no queue—just a single stateless function.

Prerequisites (All Free Tier)

WhatWhyLink
Cloudflare accountHosts the Worker and Workers AIdash.cloudflare.com (free plan)
Node.js 18+Runs Wrangler CLInodejs.org
Wrangler CLIDeploys the Workernpm install -g wrangler
GitHub accountCreate a GitHub Appgithub.com
A test repoWhere the bot will commentAny repo you own

Enable Workers AI in the Cloudflare dashboard (AI > Workers AI > Enable). You get 10k neurons (roughly 100k inferences/day with Llama 3 8B) on the free tier.

Step 1: Scaffold the Cloudflare Worker

mkdir pr-review-bot && cd pr-review-bot
npm create cloudflare@latest -- pr-review-bot -- --type hello-world
cd pr-review-bot
npm install

Open wrangler.toml and wire in Workers AI:

name = "pr-review-bot"
main = "src/index.ts"
compatibility_date = "2024-09-23"

[ai]
binding = "AI"

The [ai] binding makes Workers AI available as env.AI in your handler. No API key, no base URL—Cloudflare injects it at the edge.

Step 2: Configure Workers AI and Llama 3

Create src/ai.ts to encapsulate the inference call:

import { Ai } from '@cloudflare/ai';

export interface ReviewFinding {
  file: string;
  line: number;
  severity: 'nit' | 'bug' | 'logic';
  comment: string;
}

export async function reviewDiff(
  ai: Ai,
  diff: string
): Promise<ReviewFinding[]> {
  const prompt = `You are a senior code reviewer. Analyze the following git diff.
Return ONLY a valid JSON array of objects with keys: file, line, severity, comment.
Severity must be one of: "nit", "bug", "logic".
If you find no issues, return an empty array.
Do not include markdown fences or any text outside the JSON.

Diff:
${diff.slice(0, 8000)}`; // Truncate to stay under token limits

  const response = await ai.run('@cf/meta/llama-3-8b-instruct', {
    prompt,
    max_tokens: 1024,
    temperature: 0.1,
  });

  const text = (response as any).response || '';
  // Strip possible markdown fences
  const json = text.replace(/```json|```/g, '').trim();
  try {
    const parsed = JSON.parse(json);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    console.error('Failed to parse LLM output:', json);
    return [];
  }
}

Key decisions: temperature: 0.1 keeps it deterministic. The 8000-character truncation avoids hitting Llama 3's context window on large diffs. The prompt enforces JSON-only output—Llama 3 8B is surprisingly obedient here.

Step 3: Implement the PR Review Logic

Create src/github.ts for API interactions:

export async function getPullRequestDiff(
  owner: string,
  repo: string,
  pullNumber: number,
  token: string
): Promise<string> {
  const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`;
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github.v3.diff',
    },
  });
  if (!res.ok) throw new Error(`Failed to fetch diff: ${res.status}`);
  return res.text();
}

export async function createReview(
  owner: string,
  repo: string,
  pullNumber: number,
  commitId: string,
  findings: { path: string; line: number; body: string }[],
  token: string
): Promise<void> {
  const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`;
  const body = {
    commit_id: commitId,
    event: findings.length > 0 ? 'COMMENT' : 'APPROVE',
    comments: findings.map((f) => ({
      path: f.path,
      line: f.line,
      side: 'RIGHT',
      body: `🤖 **Llama 3 Review Bot:** ${f.body}`,
    })),
  };

  await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
}

The Accept: application/vnd.github.v3.diff header is the cleanest way to get a unified diff without parsing the full PR object.

Step 4: Set Up GitHub App for Authentication

Personal access tokens work for quick tests, but a GitHub App is the correct approach—it scopes to specific repos and generates short-lived installation tokens.

  1. Go to Settings > Developer settings > GitHub Apps > New GitHub App
  2. Name: pr-review-bot-<yourname>
  3. Homepage URL: your repo URL
  4. Webhook URL: leave blank for now (you'll fill it after deploy)
  5. Webhook secret: generate a random string, save it
  6. Permissions:
    • Pull requests: Read & Write
    • Contents: Read
  7. Subscribe to events: Pull request
  8. Create the app, then Generate a private key and download it
  9. Install the app on your test repo (Install App tab)

Note the App ID and Installation ID (from the installation URL, e.g., https://github.com/settings/installations/12345678).

Store secrets in Cloudflare:

wrangler secret put GITHUB_APP_ID
wrangler secret put GITHUB_INSTALLATION_ID
wrangler secret put GITHUB_PRIVATE_KEY  # paste the full PEM, including BEGIN/END lines
wrangler secret put WEBHOOK_SECRET

Step 5: Wire Up GitHub Webhooks

Now the main handler in src/index.ts:

import { Ai } from '@cloudflare/ai';
import { reviewDiff } from './ai';
import { getPullRequestDiff, createReview } from './github';
import { createAppJWT, getInstallationToken } from './auth';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('OK', { status: 200 });
    }

    // Verify webhook signature
    const signature = request.headers.get('x-hub-signature-256');
    if (!verifySignature(await request.clone().text(), signature, env.WEBHOOK_SECRET)) {
      return new Response('Unauthorized', { status: 401 });
    }

    const body: any = await request.json();
    const event = request.headers.get('x-github-event');

    if (
      event === 'pull_request' &&
      (body.action === 'opened' || body.action === 'synchronize')
    ) {
      const { pull_request, repository } = body;
      const owner = repository.owner.login;
      const repo = repository.name;
      const pullNumber = pull_request.number;
      const commitId = pull_request.head.sha;

      // Generate installation token
      const jwt = await createAppJWT(
        env.GITHUB_APP_ID,
        env.GITHUB_PRIVATE_KEY
      );
      const token = await getInstallationToken(
        jwt,
        env.GITHUB_INSTALLATION_ID
      );

      // Fetch diff and run review
      const diff = await getPullRequestDiff(owner, repo, pullNumber, token);
      const ai = new Ai(env.AI);
      const findings = await reviewDiff(ai, diff);

      // Post review comments
      const comments = findings.map((f) => ({
        path: f.file,
        line: f.line,
        body: `**${f.severity.toUpperCase()}**: ${f.comment}`,
      }));
      await createReview(owner, repo, pullNumber, commitId, comments, token);
    }

    return new Response('OK', { status: 200 });
  },
};

Create src/auth.ts for JWT generation:

import { SignJWT } from 'jose';

export async function createAppJWT(
  appId: string,
  privateKey: string
): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({ iss: appId })
    .setProtectedHeader({ alg: 'RS256' })
    .setIssuedAt(now)
    .setExpirationTime(now + 600)
    .sign(await crypto.subtle.importKey(
      'pkcs8',
      pemToArrayBuffer(privateKey),
      { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      false,
      ['sign']
    ));
}

export async function getInstallationToken(
  jwt: string,
  installationId: string
): Promise<string> {
  const res = await fetch(
    `https://api.github.com/app/installations/${installationId}/access_tokens`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${jwt}`,
        Accept: 'application/vnd.github.v3+json',
      },
    }
  );
  const data: any = await res.json();
  return data.token;
}

function pemToArrayBuffer(pem: string): ArrayBuffer {
  const b64 = pem
    .replace('-----BEGIN PRIVATE KEY-----', '')
    .replace('-----END PRIVATE KEY-----', '')
    .replace(/\s/g, '');
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes.buffer;
}

function verifySignature(
  payload: string,
  signature: string | null,
  secret: string
): boolean {
  // Implement HMAC-SHA256 verification using Web Crypto
  // Left as exercise—Cloudflare's crypto.subtle handles this natively
  return true; // Replace with actual verification
}

Install the jose library for JWT signing:

npm install jose

Step 6: Deploy and Test End-to-End

wrangler deploy

Copy the deployed URL (e.g., https://pr-review-bot.<your-subdomain>.workers.dev). Go back to your GitHub App settings and set the Webhook URL to this address. Paste the same webhook secret.

Create a test PR in your repo. Within seconds, the bot should post inline comments. Check wrangler tail for live logs if something goes sideways:

wrangler tail

First PR review latency: expect 3-8 seconds. Cloudflare Workers AI cold-start is negligible, but Llama 3 8B inference on an 8k-token prompt takes a few seconds. Subsequent requests hit warm models and complete faster.

Sensible Extensions

Once the baseline works, layer on these improvements:

  1. Diff chunking. Large PRs exceed the 8k token limit. Chunk the diff by file, run parallel inferences with Promise.all, and merge results.
  2. Ignore patterns. Add a .prbotignore file or respect .gitignore to skip generated files, lockfiles, and minified bundles.
  3. Context-aware prompting. Include the PR title and description in the prompt so Llama 3 understands intent. A style nit on a hotfix is different from a nit on a refactor.
  4. Comment threading. Use the GitHub API's in_reply_to field to thread bot comments under existing human reviews.
  5. Rate limiting. Workers AI free tier has generous limits, but wrap the ai.run call in a semaphore if you're reviewing multiple PRs concurrently.

For a deeper dive on shipping LLM features fast in constrained environments, see our case study on deploying an LLM feature at a bank in 5 days.

Common Pitfalls

PEM key formatting. The private key from GitHub includes \n literal characters if you copy from a .pem file. Use wrangler secret put with the raw file content, or replace \\n with actual newlines before signing.

Webhook signature verification. Skipping this in prod is a security hole. Cloudflare Workers support crypto.subtle.importKey with HMAC. Implement it—the skeleton above is intentionally incomplete so you don't deploy without it.

JSON parsing failures. Llama 3 occasionally wraps JSON in markdown fences despite explicit instructions. The replace(/```json|```/g, '') handles most cases, but add a fallback that regex-extracts arrays if parsing fails.

Inline comment positioning. The line field in GitHub's review API is 1-indexed and must correspond to the right side of the diff (the new file). If Llama 3 returns line numbers from the diff header, you'll need to offset them. Parse the @@ -a,b +c,d @@ hunks to map diff lines to file lines.

Free tier limits. Workers AI free tier includes 10k neurons/day. Llama 3 8B consumes roughly 1 neuron per 10 tokens processed. A 4k-token diff with a 1k-token prompt uses ~500 neurons. You get ~20 PR reviews/day, which is plenty for a solo dev or small team. If you scale, the paid tier is $0.011 per 1k neurons—still absurdly cheap.

If you're thinking about how this kind of automation fits into a broader engineering workflow, our breakdown of what a Forward Deployed Engineer actually does in a week shows where PR review bots slot into the daily cadence.

FAQ

Q: Why Cloudflare Workers AI instead of OpenAI or a self-hosted model? A: Zero cold-start, no credit card required, and the free tier is genuinely usable. Llama 3 8B is strong enough for code review when prompted well. No GPU to manage, no API key to rotate.

Q: Can I use a different model? A: Yes. Swap @cf/meta/llama-3-8b-instruct for any model in the Workers AI catalog. DeepSeek Coder and Code Llama are available and may produce better code-specific feedback.

Q: How do I prevent the bot from commenting on the same line multiple times? A: Store previously commented line/file tuples in Cloudflare KV (also free tier) and deduplicate before posting. The KV key can be ${owner}/${repo}/${prNumber}/${file}:${line}.

Q: What if the diff is too large for Llama 3's context window? A: The code above truncates at 8000 characters. For production, split the diff into per-file chunks, review each independently, and aggregate. You'll burn more neurons but stay within limits.

Q: Does this work with private repositories? A: Yes. Install the GitHub App on private repos. The installation token has whatever permissions you granted the app.

For teams thinking about AI-assisted development pipelines, our guide on building an on-call incident summarizer shows another zero-cost automation pattern using the same stack.

#github#code-review#cloudflare#devops

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now