Deploy a Free GitHub PR Review Bot Using Cloudflare Workers AI
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.openedandpull_request.synchronizewebhooks - 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 deployfrom 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)
| What | Why | Link |
|---|---|---|
| Cloudflare account | Hosts the Worker and Workers AI | dash.cloudflare.com (free plan) |
| Node.js 18+ | Runs Wrangler CLI | nodejs.org |
| Wrangler CLI | Deploys the Worker | npm install -g wrangler |
| GitHub account | Create a GitHub App | github.com |
| A test repo | Where the bot will comment | Any 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.
- Go to Settings > Developer settings > GitHub Apps > New GitHub App
- Name:
pr-review-bot-<yourname> - Homepage URL: your repo URL
- Webhook URL: leave blank for now (you'll fill it after deploy)
- Webhook secret: generate a random string, save it
- Permissions:
- Pull requests: Read & Write
- Contents: Read
- Subscribe to events: Pull request
- Create the app, then Generate a private key and download it
- 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:
- Diff chunking. Large PRs exceed the 8k token limit. Chunk the diff by file, run parallel inferences with
Promise.all, and merge results. - Ignore patterns. Add a
.prbotignorefile or respect.gitignoreto skip generated files, lockfiles, and minified bundles. - 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.
- Comment threading. Use the GitHub API's
in_reply_tofield to thread bot comments under existing human reviews. - Rate limiting. Workers AI free tier has generous limits, but wrap the
ai.runcall 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.
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