Deploy a Free GitHub PR Review Bot with Gemini Flash & Actions
What We're Building
A GitHub bot that automatically reviews every new pull request for bugs, style issues, and security gaps. When a developer opens a PR, the bot fetches the diff, sends it to Google Gemini Flash (free tier), and posts inline comments directly on the changed lines. No server, no credit card, no recurring costs.
Feature list:
- Triggers on
pull_request.openedandpull_request.synchronizeevents. - Fetches the PR diff using the GitHub REST API.
- Sends the diff to Gemini Flash with a structured review prompt.
- Parses the LLM response into line-specific comments.
- Posts inline review comments via Octokit.
- Skips review if the diff is empty or too large for the free-tier context window.
- Logs everything for debugging in the Actions run.
This isn't a toy. It's a production-grade pattern you can extend to enforce team-specific conventions, flag deprecated library usage, or catch secrets before they hit main.
Architecture: How the Pieces Fit
The flow is dead simple: a GitHub Actions workflow spins up a Node.js runtime on every PR event. The script uses Octokit (GitHub's official JS client) to pull the raw diff. That diff hits Gemini Flash with a system prompt that forces structured output. The response is parsed into an array of {path, line, body} objects, and Octokit posts them as review comments. No databases, no queues, no infrastructure.
Prerequisites (All Free Tier)
- GitHub repository — any public or private repo. Free tier gives you 2,000 Actions minutes/month for private repos, unlimited for public.
- Google Gemini Flash API key — grab one at makersuite.google.com/app/apikey. The free tier gives you 15 requests per minute, 1,500 per day. More than enough for a team's PR volume.
- Node.js 20+ — comes pre-installed on
ubuntu-latestrunners. You don't need it locally, but it helps for testing. - Basic GitHub Actions knowledge — if you've ever written a YAML file, you're good.
No Docker, no Vercel, no Redis. Just a YAML file and a JavaScript file sitting in .github/.
Step 1: Scaffold the GitHub Action
Create .github/workflows/pr-review.yml:
name: AI PR Review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install @google/generative-ai octokit
- run: node .github/scripts/pr-review.js
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
Two critical details:
permissions.pull-requests: writeis mandatory. Without it, Octokit can't post comments.GITHUB_TOKENis auto-generated by Actions. You don't create it manually.
Store your Gemini API key as a repository secret: Settings → Secrets and variables → Actions → New repository secret. Name it GEMINI_API_KEY.
Step 2: Wire Up the Gemini Flash Client
Create .github/scripts/pr-review.js. Start with the Gemini client factory:
const { GoogleGenerativeAI } = require("@google/generative-ai");
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
function getModel() {
return genAI.getGenerativeModel({
model: "gemini-2.0-flash",
systemInstruction: `You are a senior code reviewer. Analyze the provided git diff and return ONLY valid JSON.
You must output an array of review comments. Each comment object has these fields:
- "path": the file path (string)
- "line": the line number in the new file (integer)
- "body": the review comment, max 200 characters, concise and actionable (string)
Rules:
- Flag bugs, logic errors, security issues, and style violations.
- Ignore trivial formatting changes.
- If the code looks fine, return an empty array [].
- Do NOT wrap the JSON in markdown fences. Output raw JSON only.
- Do not include any text before or after the JSON.`
});
}
Why gemini-2.0-flash? It's the fastest model on the free tier, has a 1M token context window, and costs nothing. The system instruction is the secret sauce—it forces structured output without needing function calling. The "raw JSON only" constraint prevents markdown-wrapped responses that break JSON.parse.
Step 3: Build the Review Engine
The engine has three jobs: fetch the diff, call Gemini, parse the response.
async function fetchDiff(octokit, owner, repo, pullNumber) {
const { data } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
mediaType: { format: "diff" },
});
// data is the raw diff string when mediaType is "diff"
return data;
}
async function reviewDiff(diff) {
const model = getModel();
const prompt = `Review this git diff:\n\n${diff}`;
const result = await model.generateContent(prompt);
const response = result.response;
const text = response.text();
// Strip any accidental markdown fences
const cleaned = text.replace(/^```json\s*/, "").replace(/\s*```$/, "");
return JSON.parse(cleaned);
}
mediaType: { format: "diff" } is an Octokit trick that returns the raw unified diff instead of the full PR object. This is exactly what Gemini needs—contextual lines with @@ hunk headers help the model understand where changes sit.
If the diff exceeds ~800K characters, chunk it. The free tier handles 1M tokens, but a single massive diff can still OOM the model. For most PRs, you won't hit this.
Step 4: Post Inline Comments with Octokit
async function postComments(octokit, owner, repo, pullNumber, comments) {
if (!comments || comments.length === 0) {
console.log("No issues found. Skipping comment creation.");
return;
}
// Create a review with inline comments
const reviewComments = comments.map((c) => ({
path: c.path,
position: c.line, // Note: position, not line number directly
body: c.body,
}));
await octokit.rest.pulls.createReview({
owner,
repo,
pull_number: pullNumber,
event: "COMMENT",
comments: reviewComments,
});
console.log(`Posted ${comments.length} review comments.`);
}
Critical gotcha: GitHub's createReview endpoint expects position, which is the index in the diff hunk, not the absolute line number. The Gemini prompt asks for "line number in the new file," but mapping that to a diff position requires parsing the diff hunks.
Here's the fix—a small utility that translates file line numbers to diff positions:
function mapLineToPosition(diff, filePath, targetLine) {
const lines = diff.split("\n");
let currentFile = null;
let position = 0;
let newLine = 0;
for (const line of lines) {
if (line.startsWith("diff --git")) {
currentFile = null;
position = 0;
newLine = 0;
}
if (line.startsWith("+++ b/")) {
currentFile = line.slice(6);
}
if (currentFile !== filePath) continue;
if (line.startsWith("@@")) {
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
if (match) {
newLine = parseInt(match[2]) - 1;
position = 0;
}
continue;
}
position++;
if (!line.startsWith("-")) newLine++;
if (newLine === targetLine && !line.startsWith("-")) {
return position;
}
}
return null;
}
Call this before posting: position: mapLineToPosition(diff, c.path, c.line). If it returns null, skip that comment—it means the line isn't in the diff.
Step 5: Assemble the Workflow YAML
The full .github/scripts/pr-review.js main function:
const { Octokit } = require("octokit");
async function main() {
const [owner, repo] = process.env.REPO.split("/");
const pullNumber = parseInt(process.env.PR_NUMBER);
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
console.log(`Fetching diff for PR #${pullNumber}...`);
const diff = await fetchDiff(octokit, owner, repo, pullNumber);
if (typeof diff !== "string" || diff.length === 0) {
console.log("Empty diff. Nothing to review.");
return;
}
console.log(`Diff length: ${diff.length} chars. Sending to Gemini...`);
const comments = await reviewDiff(diff);
console.log(`Received ${comments.length} comments from Gemini.`);
await postComments(octokit, owner, repo, pullNumber, comments);
}
main().catch((err) => {
console.error("Review failed:", err);
process.exit(1);
});
Commit both files to your default branch. The action triggers on the next PR.
Step 6: Run It and See the Magic
- Push a branch with some intentionally sloppy code—an unchecked null access, a hardcoded secret, a missing error handler.
- Open a PR against main.
- Go to the Actions tab. You'll see the
AI PR Reviewworkflow kick off. - Within 30-60 seconds, inline comments appear on the PR.
First-run latency tip: npm install adds ~15 seconds. Subsequent runs benefit from GitHub's dependency caching. Add this to your workflow for faster installs:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
(You don't strictly need package-lock.json since we npm install on the fly, but if you add a package.json with pinned versions, caching helps.)
Sensible Extensions
1. File-type filtering. Skip binary files and generated code by adding a pre-filter. Check diff for paths ending in .min.js, .lock, .png, etc., and strip those hunks before sending to Gemini. This saves tokens and prevents hallucinated comments on auto-generated files.
2. Team-specific rules as a system prompt appendix. Maintain a .github/review-rules.md file. Fetch it in the action and append to the system instruction. Example: "Never use console.log in production code. Suggest logger.info instead." This turns the bot into an enforcer of team conventions without changing any code.
3. Severity labels. Extend the JSON schema to include a severity: "high" | "medium" | "low" field. Post high-severity findings as a single summary comment that blocks merge if you wire up branch protection rules.
4. Parallel file review. For large PRs, split the diff by file and fire off parallel Gemini requests. The free tier allows 15 RPM—use them. Aggregate results and deduplicate before posting.
5. Review history tracking. Log every review to a reviews/ branch or a simple SQLite file in the repo. Over time, you'll see which types of issues the bot catches most, and you can tune the prompt accordingly.
If you enjoy building automation that bridges LLMs and real workflows, you'd probably get a lot out of the patterns in The FDE Portfolio: 4 Projects to Build to Prove You Can Ship in the Customer's Chaos. This PR review bot is exactly the kind of high-signal project that demonstrates you understand the full stack—from API integration to CI/CD to prompt engineering.
Common Pitfalls
- "No comments appear." Check the Actions logs. If Gemini returned an empty array, the code was clean (or the prompt was too strict). Loosen the system instruction by adding "Be thorough. Even minor improvements count."
- "Comments are on the wrong lines." Your
mapLineToPositionis likely off. Debug by loggingnewLineandpositionfor each hunk. GitHub's diff format is finicky—the@@header tells you the starting line in the new file, and you count from there. - "Gemini returns markdown-wrapped JSON." Despite the system instruction, Flash occasionally wraps output. The
cleanedregex handles most cases, but if you see parse errors, add a fallback: try to extract the first[to the last]and parse that substring. - "Rate limited by Gemini." Free tier is 15 RPM. If you have multiple PRs opening simultaneously, add a simple retry with exponential backoff. A 1-second delay on 429 responses usually resolves it.
- "Action fails on
npm install @google/generative-ai." Theubuntu-latestrunner has Node 20, but the package might need a newer npm. Addnpm install -g npm@latestbefore the install step if you hit version conflicts.
For a deeper look at what a Forward Deployed Engineer actually ships in a week—including bots like this one built on-site with customers—check out What a Forward Deployed Engineer Actually Does in a Week. The PR review bot is a classic FDE pattern: high leverage, zero infrastructure, delivered in an afternoon.
FAQ
Q: Does this work on private repositories? Yes. GitHub Actions free tier includes 2,000 minutes/month for private repos. A typical PR review takes ~30 seconds, so you get roughly 4,000 reviews/month before hitting the cap.
Q: What if the diff is larger than Gemini Flash's context window?
Flash has a 1M token context window, which handles most PRs. For outliers, chunk the diff by file and review each chunk independently. Sum the results and deduplicate by path + line.
Q: Can I use this on monorepos with thousands of files?
Yes, but add path filtering. Only review files in changed directories relevant to your team. Skip docs/, assets/, and generated code to keep token usage low.
Q: How do I prevent the bot from reviewing its own PRs?
Add a condition to the workflow: if: github.actor != 'github-actions[bot]'. This skips runs triggered by the bot itself (e.g., if you have another action that auto-commits).
Q: Can I switch to a different free LLM?
Absolutely. Swap the @google/generative-ai package for Groq's SDK (free tier, very fast) or any OpenAI-compatible endpoint. The architecture stays identical—only the client changes. If you're curious about Groq, Build a Screenshot-to-Code Agent Using Groq Vision & Vercel in 30 Minutes walks through a similar free-tier integration.
Q: What about reviewing Jupyter notebooks or other non-text files?
Convert them to text first. For .ipynb files, extract code cells and concatenate. Add a pre-processing step in the action before sending to Gemini.
Q: Is the Gemini Flash output deterministic enough for CI? No, and that's okay. Code review is inherently subjective. The bot catches objective issues (null pointers, missing error handling) reliably. Style comments will vary run-to-run. If you need deterministic linting, pair this with ESLint or Pylint—use the bot for the fuzzy stuff, linters for the hard rules.
Q: How do I test the script locally before pushing?
Export the environment variables (GEMINI_API_KEY, GITHUB_TOKEN with a personal access token, PR_NUMBER, REPO) and run node .github/scripts/pr-review.js. Use a test PR on a fork to avoid spamming your team's repo with test comments.
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