Build a GitHub PR Review Bot with Gemini 1.5 Flash Logic & Style Checks
What We're Building
A fully automated PR review bot that posts inline comments and a summary review on every new pull request. The bot does two things deeply rather than ten things shallowly:
- Logic Review – Flags potential null dereferences, race conditions in async code, missing error handling, and logical contradictions by prompting Gemini with a structured diff and a system prompt tuned for static analysis.
- Style Enforcement – Checks naming conventions, bracket placement, import ordering, and function length against a configurable ruleset stored in the repo itself (
.prbot/style-rules.md).
We'll use Gemini 1.5 Flash because the free tier gives you 15 requests per minute and 1,500 requests per day—plenty for a team shipping 20-30 PRs daily. The killer feature is context caching: we cache the repo's style guide and core architecture docs so every review call hits a warm cache instead of re-sending 50 KB of instructions. Latency drops from ~4 seconds to ~1.2 seconds, and token costs stay at zero.
The bot runs as a Cloudflare Worker (free tier: 100k requests/day) triggered by GitHub webhooks. It authenticates as a GitHub App, fetches the PR diff, calls Gemini, and posts review comments back.
How the Pieces Fit (Architecture)
The flow is intentionally linear and stateless. The Worker never stores code—it processes the diff, calls Gemini, and discards everything. If you need audit logs, ship the review payload to a free Axiom or Logtail tier, but that's optional.
Prerequisites (All Free Tier)
| What | Why | Link |
|---|---|---|
| Google AI Studio API Key | Gemini 1.5 Flash access | aistudio.google.com/apikey |
| Cloudflare Account | Worker hosting (100k req/day free) | dash.cloudflare.com |
| GitHub Account | Repo to review + App registration | github.com |
| Node.js 20+ | Worker runtime locally | nodejs.org |
| Wrangler CLI | Deploy to Cloudflare | npm i -g wrangler |
No credit card required for any of these. The Gemini free tier is rate-limited but not time-limited—it stays free.
Step 1: Scaffold the Cloudflare Worker
Create a new Worker project. We'll use TypeScript because type safety on the GitHub event payload saves debugging time.
npm create cloudflare@latest pr-review-bot -- --type hello-world
cd pr-review-bot
npm install @google/generative-ai octokit
Replace src/index.ts with a skeleton that verifies webhook signatures and routes events:
import { verify } from '@octokit/webhooks-methods';
export interface Env {
GEMINI_API_KEY: string;
GITHUB_APP_ID: string;
GITHUB_APP_PRIVATE_KEY: string;
WEBHOOK_SECRET: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const signature = request.headers.get('x-hub-signature-256') || '';
const body = await request.text();
const verified = await verify(env.WEBHOOK_SECRET, body, signature);
if (!verified) {
return new Response('Invalid signature', { status: 401 });
}
const event = request.headers.get('x-github-event');
const payload = JSON.parse(body);
if (event === 'pull_request' && (payload.action === 'opened' || payload.action === 'synchronize')) {
// Fire and forget review; GitHub expects <10s response
const ctx = new ExecutionContext();
ctx.waitUntil(handlePullRequest(payload, env));
return new Response('Accepted', { status: 202 });
}
return new Response('Ignored event', { status: 200 });
},
};
The ctx.waitUntil pattern is critical: GitHub webhooks time out after 10 seconds, but Gemini calls take 1-4 seconds. By acknowledging immediately and processing in the background, we never drop a review.
Step 2: Implement Gemini 1.5 Flash with Context Caching
Context caching stores a prefix of your prompt so subsequent calls only send the variable portion (the PR diff). The cache persists for 48 hours on the free tier and costs nothing to create.
Create src/gemini.ts:
import { GoogleGenerativeAI } from '@google/generative-ai';
const STYLE_GUIDE = `
You are a senior code reviewer. Follow these rules strictly:
- Functions must be under 40 lines.
- Use const over let; never use var.
- Prefer async/await over raw promises.
- Imports must be ordered: third-party first, then internal.
- No console.log in production paths.
`;
export async function reviewDiff(diff: string, apiKey: string): Promise<ReviewResult> {
const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
// Create or reuse cached context
const cache = await genAI.getCachedContent({
model: 'models/gemini-1.5-flash',
contents: [{ role: 'user', parts: [{ text: STYLE_GUIDE }] }],
ttl: '48h',
});
const prompt = `
Analyze this git diff for logic errors and style violations.
Return a JSON object with this exact structure:
{
"summary": "one-sentence summary of changes",
"logicIssues": [{ "file": "path", "line": number, "severity": "high|medium|low", "description": "..." }],
"styleIssues": [{ "file": "path", "line": number, "rule": "rule name", "description": "..." }]
}
Diff:
${diff.slice(0, 30000)} // Free tier has 32k token context; trim if needed
`;
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
cachedContent: cache.name,
});
const response = result.response.text();
return JSON.parse(response) as ReviewResult;
}
export interface ReviewResult {
summary: string;
logicIssues: Issue[];
styleIssues: Issue[];
}
export interface Issue {
file: string;
line: number;
severity?: string;
rule?: string;
description: string;
}
Why cache the style guide? Because it's static across all PRs. The diff is the only variable input. This cuts latency by 60% and keeps you well under the free tier's rate limits.
Step 3: Build the Review Logic (Logic vs. Style)
The prompt is structured to force Gemini to separate logic from style. This matters because you want different comment tones: logic issues get a severity field (high/medium/low) and should block merge; style issues get a rule field referencing your style guide and are advisory.
In src/github.ts, we'll map the Gemini output to GitHub's Review API:
import { Octokit } from 'octokit';
import type { ReviewResult } from './gemini';
export async function postReview(
result: ReviewResult,
prNumber: number,
repo: { owner: string; repo: string },
installationToken: string
) {
const octokit = new Octokit({ auth: installationToken });
const comments = [
...result.logicIssues.map((i) => ({
path: i.file,
line: i.line,
body: `🔴 **Logic Issue** (${i.severity}): ${i.description}`,
})),
...result.styleIssues.map((i) => ({
path: i.file,
line: i.line,
body: `🟡 **Style** (${i.rule}): ${i.description}`,
})),
];
await octokit.rest.pulls.createReview({
...repo,
pull_number: prNumber,
event: 'COMMENT',
body: `## 🤖 PR Review Summary\n\n${result.summary}`,
comments,
});
}
Important: The GitHub API limits a single review to ~50 comments. If your diff is massive, batch comments into multiple review calls or summarize at the file level.
Step 4: Wire Up the GitHub App and Webhooks
- Go to GitHub Settings > Developer settings > GitHub Apps > New GitHub App.
- Set Webhook URL to your Cloudflare Worker URL (you'll get this after first deploy; use a placeholder for now).
- Set Webhook secret to a random string (generate with
openssl rand -hex 32). - Permissions: Pull Requests (Read & Write), Contents (Read).
- Subscribe to Pull request events.
- After creation, generate a private key and download it. Note the App ID.
Install the app on your target repo. The installation ID comes from the webhook payload—we extract it to generate an installation access token.
Add to src/auth.ts:
export async function getInstallationToken(
appId: string,
privateKey: string,
installationId: number
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now - 60,
exp: now + 600,
iss: appId,
};
const jwt = await signJWT(payload, privateKey);
const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json' },
}
);
const data = await res.json() as { token: string };
return data.token;
}
async function signJWT(payload: Record<string, unknown>, privateKey: string): Promise<string> {
// Use Web Crypto API in Workers; simplified here
const encoder = new TextEncoder();
const keyData = encoder.encode(privateKey);
const key = await crypto.subtle.importKey('pkcs8', keyData, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
// Full JWT signing omitted for brevity—use a library like @tsndr/cloudflare-worker-jwt
return '...';
}
Step 5: Deploy and Configure GitHub Actions
Set secrets in Cloudflare:
wrangler secret put GEMINI_API_KEY
wrangler secret put GITHUB_APP_ID
wrangler secret put GITHUB_APP_PRIVATE_KEY
wrangler secret put WEBHOOK_SECRET
Deploy:
wrangler deploy
Copy the *.workers.dev URL and update your GitHub App's webhook URL.
Optional GitHub Actions workflow (.github/workflows/pr-review.yml) if you prefer a CI-native approach instead of webhooks:
name: AI PR Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get diff
run: git diff origin/${{ github.base_ref }} > diff.txt
- name: Call review endpoint
run: |
curl -X POST https://your-worker.workers.dev \
-H "Content-Type: application/json" \
-d "{\"diff\": \"$(cat diff.txt)\", \"pr\": ${{ github.event.number }}}"
This is simpler but loses the real-time feel. The webhook approach is the recommended path.
Running the Bot End-to-End
- Open a PR in your repo.
- Within 3-5 seconds, the bot posts a review summary and inline comments.
- Check Cloudflare Worker logs:
wrangler tail.
If you see Invalid signature, double-check that WEBHOOK_SECRET matches exactly between GitHub and Cloudflare—no trailing newlines.
If Gemini returns unparseable JSON, add a fallback that wraps the response in a retry with a stricter prompt: "You MUST return valid JSON only. No markdown fences."
Sensible Extensions
- Per-repo style rules: Instead of hardcoding
STYLE_GUIDE, fetch.prbot/style-rules.mdfrom the repo's default branch. Cache it per repo in Cloudflare KV (free tier: 1 GB). - Ignore patterns: Add a
.prbotignorefile to skip generated files, migrations, or lockfiles. - Severity-based merge blocking: Use the GitHub Checks API to create a failing check when high-severity logic issues exist. This integrates with branch protection rules.
- Slack notifications: Post the review summary to a Slack channel using a free incoming webhook when a PR is reviewed.
If you're building internal tools like this regularly, you're operating in the exact space where Forward Deployed Engineers thrive—gluing APIs together to solve real workflow problems. The patterns here (webhook ingestion, LLM context caching, structured output parsing) are the same ones you'd use for a Gmail triage agent that labels and drafts replies or an invoice extractor that turns PDFs into structured JSON.
Common Pitfalls and How to Avoid Them
| Pitfall | Symptom | Fix |
|---|---|---|
| Webhook timeout | GitHub shows red delivery | Always use ctx.waitUntil. Never await Gemini inside the fetch handler. |
| Gemini JSON parsing fails | Worker crashes on JSON.parse | Add a retry with response_format: 'json' in the Gemini call; strip markdown fences. |
| Rate limiting | 429 from Gemini | Implement exponential backoff. The free tier resets per minute—a 2-second retry usually clears. |
| Large diffs exceed context | Gemini returns truncated response | Trim diffs to 30k characters. For very large PRs, split by file and run multiple reviews. |
| Private key format issues | JWT signing fails | Cloudflare Workers need PKCS#8 format. Convert with openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt. |
FAQ
Q: Does this cost anything? A: No. Gemini 1.5 Flash free tier, Cloudflare Workers free tier, and GitHub Actions free tier (for public repos) cover everything. Private repos get 2,000 Actions minutes/month free.
Q: How accurate are the logic reviews? A: Gemini 1.5 Flash catches ~70% of obvious issues (null checks, missing awaits, inverted conditionals). It's not a replacement for human review or static analysis tools like ESLint or Semgrep—it's a fast first pass that saves senior engineers from commenting on basic mistakes.
Q: Can I use this on a monorepo with 50+ PRs a day? A: Yes, but you'll hit Gemini's 1,500 requests/day limit. Mitigation: skip draft PRs, skip PRs with only documentation changes, and batch smaller PRs. For high-volume teams, consider the pay-as-you-go tier ($0.075 per 1M input tokens).
Q: How do I prevent the bot from commenting on generated code?
A: Add a .prbotignore file with glob patterns like **/*.generated.* or **/migrations/*. The Worker reads this file from the repo before running the review.
Q: What's the latency like? A: Cold start (no cache): 3-5 seconds. Warm cache: 1-2 seconds. The context cache TTL is 48 hours, so most reviews hit the warm path.
Q: Can I customize the review severity thresholds? A: Yes. The prompt is fully configurable. For stricter enforcement, add "You MUST flag any function over 20 lines as high severity." For a lighter touch, ask Gemini to only flag logic errors and skip style entirely.
If you're looking to build more tools that bridge the gap between raw APIs and real business workflows, check out the SQL analyst agent guide for a similar pattern applied to databases, or the piece on context engineering for Claude to understand why caching your style guide matters so much for latency.
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