| [ Web Proxy ] |
| Viewing: https://developers.cloudflare.com/sandbox/tutorials/code-review-bot/ | [Back] [Original] |
Build a GitHub bot that responds to pull requests, clones the repository in a sandbox, uses Claude to analyze code changes, and posts review comments.
Time to complete: 30 minutes
Node.js .Node.js version manager
Use a Node version manager like Volta or nvm to avoid permission issues and change Node.js versions. Wrangler, discussed later in this guide, requires a Node version of 16.17.0 or later.
You'll also need:
npm create cloudflare@latest -- code-review-bot --template=cloudflare/sandbox-sdk/examples/minimalyarn create cloudflare code-review-bot --template=cloudflare/sandbox-sdk/examples/minimalpnpm create cloudflare@latest code-review-bot --template=cloudflare/sandbox-sdk/examples/minimalcd code-review-bot
npm i @anthropic-ai/sdk @octokit/restyarn add @anthropic-ai/sdk @octokit/restpnpm add @anthropic-ai/sdk @octokit/restbun add @anthropic-ai/sdk @octokit/restReplace src/index.ts:
import { getSandbox, proxyToSandbox, type Sandbox } from "@cloudflare/sandbox";
import { Octokit } from "@octokit/rest";
import Anthropic from "@anthropic-ai/sdk";
export { Sandbox } from "@cloudflare/sandbox";
interface Env {
Sandbox: DurableObjectNamespace<Sandbox>;
GITHUB_TOKEN: string;
ANTHROPIC_API_KEY: string;
WEBHOOK_SECRET: string;
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const proxyResponse = await proxyToSandbox(request, env);
if (proxyResponse) return proxyResponse;
const url = new URL(request.url);
if (url.pathname === "/webhook" && request.method === "POST") {
const signature = request.headers.get("x-hub-signature-256");
const contentType = request.headers.get("content-type") || "";
const body = await request.text();
// Verify webhook signature
if (
!signature ||
!(await verifySignature(body, signature, env.WEBHOOK_SECRET))
) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
const event = request.headers.get("x-github-event");
// Parse payload (GitHub can send as JSON or form-encoded)
let payload;
if (contentType.includes("application/json")) {
payload = JSON.parse(body);
} else {
// Handle form-encoded payload
const params = new URLSearchParams(body);
payload = JSON.parse(params.get("payload") || "{}");
}
// Handle opened and reopened PRs
if (
event === "pull_request" &&
(payload.action === "opened" || payload.action === "reopened")
) {
console.log(`Starting review for PR #${payload.pull_request.number}`);
// Use waitUntil to ensure the review completes even after response is sent
ctx.waitUntil(
reviewPullRequest(payload, env).catch(console.error),
);
return Response.json({ message: "Review started" });
}
return Response.json({ message: "Event ignored" });
}
return new Response(
"Code Review Bot\n\nConfigure GitHub webhook to POST /webhook",
);
},
};
async function verifySignature(
payload: string,
signature: string,
secret: string,
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signatureBytes = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(payload),
);
const expected =
"sha256=" +
Array.from(new Uint8Array(signatureBytes))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return signature === expected;
}
async function reviewPullRequest(payload: any, env: Env): Promise<void> {
const pr = payload.pull_request;
const repo = payload.repository;
const octokit = new Octokit({ auth: env.GITHUB_TOKEN });
const sandbox = getSandbox(env.Sandbox, `review-${pr.number}`);
try {
// Post initial comment
console.log("Posting initial comment...");
await octokit.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: pr.number,
body: "Code review in progress...",
});
// Clone repository
console.log("Cloning repository...");
const cloneUrl = `https://${env.GITHUB_TOKEN}@github.com/${repo.owner.login}/${repo.name}.git`;
await sandbox.exec(
`git clone --depth=1 --branch=${pr.head.ref} ${cloneUrl} /workspace/repo`,
);
// Get changed files
console.log("Fetching changed files...");
const comparison = await octokit.repos.compareCommits({
owner: repo.owner.login,
repo: repo.name,
base: pr.base.sha,
head: pr.head.sha,
});
const files = [];
for (const file of (comparison.data.files || []).slice(0, 5)) {
if (file.status !== "removed") {
const content = await sandbox.readFile(
`/workspace/repo/${file.filename}`,
);
files.push({
path: file.filename,
patch: file.patch || "",
content: content.content,
});
}
}
// Generate review with Claude
console.log(`Analyzing ${files.length} files with Claude...`);
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
const response = await anthropic.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [
{
role: "user",
content: `Review this PR:
Title: ${pr.title}
Changed files:
${files.map((f) => `File: ${f.path}\nDiff:\n${f.patch}\n\nContent:\n${f.content.substring(0, 1000)}`).join("\n\n")}
Provide a brief code review focusing on bugs, security, and best practices.`,
},
],
});
const review =
response.content[0]?.type === "text"
? response.content[0].text
: "No review generated";
// Post review comment
console.log("Posting review...");
await octokit.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: pr.number,
body: `## Code Review\n\n${review}\n\n---\n*Generated by Claude*`,
});
console.log("Review complete!");
} catch (error: any) {
console.error("Review failed:", error);
await octokit.issues.createComment({
owner: repo.owner.login,
repo: repo.name,
issue_number: pr.number,
body: `Review failed: ${error.message}`,
});
} finally {
await sandbox.destroy();
}
}
Create a .dev.vars file in your project root for local development:
cat > .dev.vars << EOF
GITHUB_TOKEN=your_github_token_here
ANTHROPIC_API_KEY=your_anthropic_key_here
WEBHOOK_SECRET=your_webhook_secret_here
EOF
Replace the placeholder values with:
GITHUB_TOKEN: Your GitHub personal access token with repo permissionsANTHROPIC_API_KEY: Your API key from the Anthropic Console WEBHOOK_SECRET: A random string (for example: openssl rand -hex 32)Note
The .dev.vars file is automatically gitignored and only used during local development with npm run dev.
To test with real GitHub webhooks locally, use Cloudflare Tunnel to expose your local development server.
Start the development server:
npm run dev
In a separate terminal, create a tunnel to your local server:
cloudflared tunnel --url http://localhost:8787
This will output a public URL (for example, https://example.trycloudflare.com). Copy this URL for the next step.
Note
If you do not have cloudflared installed, refer to Downloads.
Important
Configure this webhook on a specific GitHub repository where you will create test pull requests. The bot will only review PRs in repositories where the webhook is configured.
/webhook appended (for example, https://example.trycloudflare.com/webhook)application/jsonWEBHOOK_SECRET in your .dev.vars fileCreate a test PR:
git checkout -b test-review
echo "console.log('test');" > test.js
git add test.js
git commit -m "Add test file"
git push origin test-review
Open the PR on GitHub. The bot should post a review comment within a few seconds.
Deploy your Worker:
npx wrangler deploy
Then set your production secrets:
# GitHub token (needs repo permissions)
npx wrangler secret put GITHUB_TOKEN
# Anthropic API key
npx wrangler secret put ANTHROPIC_API_KEY
# Webhook secret (use the same value from .dev.vars)
npx wrangler secret put WEBHOOK_SECRET
https://code-review-bot.YOUR_SUBDOMAIN.workers.dev/webhookYour bot is now running in production and will review all new pull requests automatically.
A GitHub code review bot that:
| Web Proxy Viewer | New URL | Original Page |