
AI is making it much easier to write code. As an individual developer, I use AI quite often while working on my Android projects. But there is one thing that is easy to forget when you’re working alone: reviewing your own code.
When AI helps you write a feature, it’s very easy to accept the generated code, test that it works, and move on to the next task. There isn’t always another developer around to look at the code and point out potential issues.
I faced this myself.
So I built a small AI-powered PR code reviewer using Gemini and GitHub Actions. I’ve now been using it for about a month on my Android project, and honestly, it has been really helpful. It doesn’t replace my own review or testing, but it gives me another pair of eyes before I merge a PR.

Whenever I open a PR or push new changes, GitHub automatically runs the workflow and sends the changed code to Gemini for review. The review then appears directly in the PR comment.
Step — 1. Create the GitHub Actions Workflow
First, create this file in your Android project:
.github/workflows/ai-code-review.yml
Add the following code in ai-code-review.yml file
name: AI Code Review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
gemini-review:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Run Gemini Code Review
run: python scripts/ai_code_review.py
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
There are two important things to notice here:
- GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
- GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
We’ll configure both of these in GitHub next.
Step — 2. Add Your Gemini API Key to GitHub
Never put your Gemini API key directly inside Python or YAML. Instead, go to your GitHub repository and open:
Settings → Secrets and variables → Actions
Under Repository secrets, create: GEMINI_API_KEY
and paste your Gemini API key as the value. Your repository will now have something like: Repository secrets GEMINI_API_KEY
The workflow can access it using: ${{ secrets.GEMINI_API_KEY }}
This keeps the key out of your source code.

Step — 3. Add the Gemini Model as a Repository Variable
I also don’t want to hardcode the model name in the workflow.
Under: Settings → Secrets and variables → Actions → Variables
create: GEMINI_MODEL For example: GEMINI_MODEL=gemini-3.5-flash
The exact model you use can be changed later without modifying the workflow. The workflow reads it using: ${{ vars.GEMINI_MODEL }}

Step — 4. Create the Python Script
Now create: scripts/ai_code_review.py
The script has three main jobs:
- Get the pull request diff from GitHub.
- Send the diff to Gemini.
- Post Gemini’s response back to the PR.
Here’s a simplified implementation:
import json
import os
import sys
import requests
# ---------------------------------------------------------
# Environment
# ---------------------------------------------------------
GEMINI_API_KEY = os.environ["GEMINI_API_KEY"]
GEMINI_MODEL = os.environ.get("GEMINI_MODEL") or "gemini-3.5-flash"
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
GITHUB_REPOSITORY = os.environ["GITHUB_REPOSITORY"] # "owner/repo"
PR_NUMBER = os.environ.get("PR_NUMBER")
if not PR_NUMBER:
print("This workflow is not running for a Pull Request.")
sys.exit(0)
GITHUB_API = os.environ.get("GITHUB_API_URL", "https://api.github.com")
GITHUB_HEADERS = {
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
GEMINI_URL = (
"https://generativelanguage.googleapis.com/v1beta/models/"
f"{GEMINI_MODEL}:generateContent"
)
COMMENT_MARKER = "<!-- gemini-code-review -->"
# ---------------------------------------------------------
# GitHub helpers
# ---------------------------------------------------------
def github_get(url, accept=None):
headers = dict(GITHUB_HEADERS)
if accept:
headers["Accept"] = accept
response = requests.get(url, headers=headers, timeout=60)
if not response.ok:
print("GitHub GET failed:")
print(response.status_code)
print(response.text)
response.raise_for_status()
return response
def github_post(url, payload):
response = requests.post(url, headers=GITHUB_HEADERS, json=payload, timeout=60)
if not response.ok:
print("GitHub POST failed:")
print(response.status_code)
print(response.text)
response.raise_for_status()
return response.json()
def github_patch(url, payload):
response = requests.patch(url, headers=GITHUB_HEADERS, json=payload, timeout=60)
if not response.ok:
print("GitHub PATCH failed:")
print(response.status_code)
print(response.text)
response.raise_for_status()
return response.json()
REPO_BASE = f"{GITHUB_API}/repos/{GITHUB_REPOSITORY}"
# ---------------------------------------------------------
# Get PR information
# ---------------------------------------------------------
def get_pull_request():
url = f"{REPO_BASE}/pulls/{PR_NUMBER}"
return github_get(url).json()
# ---------------------------------------------------------
# Get PR diff (GitHub returns a ready-made unified diff)
# ---------------------------------------------------------
def get_pr_diff():
url = f"{REPO_BASE}/pulls/{PR_NUMBER}"
return github_get(url, accept="application/vnd.github.v3.diff").text
# ---------------------------------------------------------
# Filter files
# ---------------------------------------------------------
ALLOWED_EXTENSIONS = {
".kt",
".kts",
".java",
".swift",
".xml",
".gradle",
}
IGNORED_PARTS = {
"/build/",
"/.gradle/",
"/generated/",
}
def should_review(path):
path_lower = path.lower()
for ignored in IGNORED_PARTS:
if ignored in path_lower:
return False
return any(
path_lower.endswith(extension)
for extension in ALLOWED_EXTENSIONS
)
# ---------------------------------------------------------
# Keep only reviewable files from the unified diff
#
# A git diff is a sequence of per-file blocks, each starting with
# "diff --git a/<path> b/<path>". We keep a block only if its path
# passes should_review().
# ---------------------------------------------------------
def build_review_input(raw_diff):
sections = []
current_path = None
current_lines = []
keep = False
def flush():
if keep and current_lines:
sections.append("".join(current_lines))
for line in raw_diff.splitlines(keepends=True):
if line.startswith("diff --git "):
flush()
current_lines = [line]
# "diff --git a/foo b/foo" -> take the b/ path
parts = line.split(" b/", 1)
current_path = parts[1].strip() if len(parts) == 2 else None
keep = bool(current_path) and should_review(current_path)
else:
current_lines.append(line)
flush()
return "n".join(sections) if sections else None
# ---------------------------------------------------------
# Gemini
# ---------------------------------------------------------
SYSTEM_PROMPT = """
You are an expert Kotlin / Android code reviewer.
You are reviewing a Pull Request. You are given unified diffs of the
changed files. Lines starting with '+' are added, '-' are removed.
Focus ONLY on problems introduced or affected by the PR.
Pay special attention to:
- Kotlin correctness
- Android architecture and lifecycle issues
- Kotlin coroutines, Dispatchers, threading, race conditions
- memory leaks
- Compose performance and incorrect state handling
- resource handling, concurrency, performance
- security
- crashes
- Gradle configuration
- unnecessary allocations
Do NOT report:
- formatting preferences
- subjective style opinions
- harmless refactoring
- issues unrelated to the PR
- hypothetical issues without reasonable evidence
Only report issues when there is reasonable confidence that they
represent a real problem.
For "line", give your best estimate of the line number in the NEW file
based on the diff. If unsure, omit it.
Return ONLY valid JSON with exactly this structure:
{
"summary": "Short overall review",
"risk": "low|medium|high|critical",
"findings": [
{
"severity": "low|medium|high|critical",
"file": "path/to/file.kt",
"line": 123,
"title": "Short issue title",
"description": "Explain the problem",
"suggestion": "Explain how to fix it"
}
]
}
"""
def review_with_gemini(pr, review_input):
prompt = f"""
{SYSTEM_PROMPT}
Pull Request:
Title:
{pr.get("title", "")}
Description:
{pr.get("body", "")}
Source branch:
{pr.get("head", {}).get("ref", "")}
Target branch:
{pr.get("base", {}).get("ref", "")}
Changed files (unified diffs):
{review_input}
"""
payload = {
"contents": [
{
"role": "user",
"parts": [{"text": prompt}],
}
],
"generationConfig": {
"temperature": 0.1,
"responseMimeType": "application/json",
},
}
response = requests.post(
GEMINI_URL,
headers={
"Content-Type": "application/json",
"x-goog-api-key": GEMINI_API_KEY,
},
json=payload,
timeout=180,
)
if not response.ok:
print("Gemini API request failed:")
print(response.status_code)
print(response.text)
response.raise_for_status()
data = response.json()
try:
text = data["candidates"][0]["content"]["parts"][0]["text"]
except (KeyError, IndexError):
print("Unexpected Gemini response:")
print(json.dumps(data, indent=2))
raise
return json.loads(text)
# ---------------------------------------------------------
# Format PR comment
# ---------------------------------------------------------
def format_comment(review):
risk = review.get("risk", "unknown").upper()
summary = review.get("summary", "No summary was provided.")
findings = review.get("findings", [])
lines = [
COMMENT_MARKER,
"## Gemini Code Review",
"",
f"**Risk:** `{risk}`",
"",
"### Summary",
"",
summary,
"",
]
if not findings:
lines.extend([
"### ✅ No issues found",
"",
"Gemini did not identify any significant issues "
"in the reviewed changes.",
])
return "n".join(lines)
lines.extend(["### Findings", ""])
severity_icons = {
"critical": "🔴",
"high": "🔴",
"medium": "🟡",
"low": "🔵",
}
for finding in findings:
severity = str(finding.get("severity", "medium")).lower()
icon = severity_icons.get(severity, "🔵")
file_path = finding.get("file", "unknown")
line = finding.get("line", "?")
title = finding.get("title", "Issue")
description = finding.get("description", "")
suggestion = finding.get("suggestion", "")
lines.extend([
f"#### {icon} {title}",
"",
f"`{file_path}:{line}`",
"",
description,
"",
"**Suggestion:**",
"",
suggestion,
"",
])
lines.extend([
"---",
"",
f"*Generated automatically by {GEMINI_MODEL}.*",
])
return "n".join(lines)
# ---------------------------------------------------------
# Post / update PR comment (dedup: reuse existing comment)
#
# A PR is an issue for the comments API.
# ---------------------------------------------------------
def find_existing_comment():
url = f"{REPO_BASE}/issues/{PR_NUMBER}/comments?per_page=100"
for comment in github_get(url).json():
if COMMENT_MARKER in (comment.get("body") or ""):
return comment.get("id")
return None
def upsert_comment(comment):
comment_id = find_existing_comment()
if comment_id:
url = f"{REPO_BASE}/issues/comments/{comment_id}"
github_patch(url, {"body": comment})
print(f"Updated existing review comment ({comment_id}).")
return
url = f"{REPO_BASE}/issues/{PR_NUMBER}/comments"
github_post(url, {"body": comment})
print("Posted new review comment.")
# ---------------------------------------------------------
# Main
# ---------------------------------------------------------
def main():
print(f"Reviewing PR #{PR_NUMBER}")
pr = get_pull_request()
print(f"PR title: {pr.get('title', '')}")
raw_diff = get_pr_diff()
review_input = build_review_input(raw_diff)
if not review_input:
print("No supported source files changed.")
return
# Safety limit on prompt size.
max_chars = 120_000
if len(review_input) > max_chars:
print(f"Diff too large ({len(review_input)} chars); truncating.")
review_input = review_input[:max_chars]
print("Sending changes to Gemini...")
review = review_with_gemini(pr, review_input)
print(json.dumps(review, indent=2))
comment = format_comment(review)
print("Posting review to GitHub...")
upsert_comment(comment)
print("✅ Gemini review posted successfully.")
if __name__ == "__main__":
main()
The exact implementation can be improved further, but this shows the basic idea.
Step — 5. Getting the Pull Request Changes
This part is handled by:
def get_pr_diff():
The script uses the GitHub Pull Request API and requests the PR as a diff.
So instead of sending my entire Android project to Gemini, I can focus on the code that actually changed.
For example, if I modify:
ProfileScreen.kt
ProfileViewModel.kt
UserRepository.kt
the reviewer can focus on those changes.
That’s important because sending an entire Android project to an AI model would be unnecessary and could also increase cost and processing time.
Step — 6. Sending the Code to Gemini
review = review_with_gemini(diff)
I specifically tell Gemini what I want it to look for.
For an Android project, I care about things like:
- Coroutine misuse
- Lifecycle problems
- Compose recomposition
- Memory leaks
- Performance
- Security
- Possible crashes
- Architecture issues
I also tell it not to report every tiny suggestion. For me, this is important. I don’t want a PR full of AI-generated comments like: “You could rename this variable.” I want the reviewer to focus on things that could actually matter.
Step — 7. Posting the Review Back to GitHub
Once Gemini returns the review, the script sends it back to GitHub:
post_review(review)
The result appears directly inside the pull request. Something like:

Why This Has Been Useful for Me
I’ve been using this setup for around a month on my Android project. It’s not perfect, and I don’t blindly accept everything it says. But it has been useful as a second pair of eyes.
When I’m developing alone, especially when AI is helping me write a lot of the code, it’s very easy to think: “The app works, so let’s merge it.”
Having an automatic review step makes me stop and look at the changes again. Sometimes it doesn’t find anything. Sometimes it catches something I completely missed. That’s already enough value for me.
Final Thoughts
This started as a small experiment to solve a simple problem: how can an individual developer get an extra code review without having another developer available? And the best part is that you don’t need to build and maintain a separate backend.
For me, AI isn’t replacing code review. It’s simply adding another step before I merge my code. After using it for a month, I now consider it a useful part of my Android development workflow.
If you run into any issues while setting this up or have any questions, feel free to leave a comment below. I’ll be happy to help, and I’d also love to hear how you’re using AI code reviews in your own projects.
Build Your Own AI Code Reviewer for Free with Gemini and GitHub Actions was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.