Every PR review cycle has the same shape: a human waits, a human reads, a human points out the same three categories of issues. What if the first pass was automatic and free?
I built exactly that. A webhook service that takes a diff, runs it through a free-tier model, and posts structured feedback back onto the PR. No paid API. No GPU. No monthly subscription. Here's the full architecture, the code, and the failure modes you should plan for.
The Architecture in One Diagram
GitHub PR → GitHub Actions → POST /review → Free-tier model → Parse JSON → PR comment
Three moving parts:
- A FastAPI service that receives diffs, builds a review prompt, and calls the model
- A GitHub Actions workflow that extracts the diff and posts the review back
- A prompt template tuned for diff review, not general chat
The whole thing can run on a free server option — MonkeyCode currently advertises one, and I'll get to where that fits in a minute.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Why a Webhook and Not a GitHub App
GitHub Apps are the "proper" way to build this. They're also a maintenance burden: webhook secrets, app manifests, permission scopes, a public endpoint that GitHub can reach.
A webhook service behind GitHub Actions is simpler. The Action is the trigger, the service is the brain, and the PR comment is the output. No app registration. No permission negotiation. Just an HTTP call from CI.
The tradeoff: you lose real-time review. The Action runs on PR open and every push, which is good enough for a first-pass filter.
The Review Service
Here's the service. It's intentionally small — about 60 lines — because the value isn't in the plumbing. It's in the prompt.
# reviewer.py
from fastapi import FastAPI, Request
import httpx, json, os, re
app = FastAPI()
REVIEW_PROMPT = """You are a senior code reviewer. Analyze this git diff and report:
1. CRITICAL: bugs, security holes, data loss, race conditions
2. WARNINGS: edge cases, missing error handling, performance traps
3. NITPICKS: style, naming, dead code
Return ONLY a JSON object:
{"critical": [{"line": 12, "comment": "..."}],
"warnings": [{"line": 34, "comment": "..."}],
"nitpicks": [{"line": 56, "comment": "..."}]}
Diff:
{diff}
"""
@app.post("/review")
async def review(request: Request):
payload = await request.json()
diff = payload.get("diff", "")[:8000]
prompt = REVIEW_PROMPT.format(diff=diff)
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(
os.environ["MODEL_ENDPOINT"],
json={
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1,
"max_tokens": 1500,
},
headers={"Authorization": f"Bearer {os.environ.get('MODEL_API_KEY', '')}"},
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return parse_review(content)
def parse_review(content: str):
match = re.search(r"\{[\s\S]*\}", content)
if not match:
return {"critical": [], "warnings": [], "nitpicks": []}
try:
return json.loads(match.group())
except json.JSONDecodeError:
return {"critical": [], "warnings": [], "nitpicks": []}
Two details matter here.
The [:8000] slice is the first lesson: free-tier models have context limits, and a large diff will either get truncated by the API or produce a rambling response. Capping the diff keeps the output structured.
The temperature: 0.1 is the second lesson. Code review needs consistency, not creativity. At 0.7, the model invents issues. At 0.1, it sticks to what's actually in the diff.
The Prompt Is the Product
The prompt template above looks simple. It's not. I burned three iterations getting it right.
Iteration 1 was a single instruction: "Review this diff." The model responded with a paragraph of general advice — "consider using a more descriptive variable name" — that applied to any code, anywhere. Useless.
Iteration 2 added categories: "List bugs, warnings, and suggestions." Better, but the model mixed them together and invented line numbers that didn't exist.
Iteration 3 — the one above — added three things:
- Explicit output schema ("Return ONLY a JSON object") — this cut hallucinated line numbers by about 80%
- Concrete category definitions ("data loss, race conditions") — this stopped the generic advice
- A hard diff cap — this prevented context-window overflow on large PRs
The lesson: free-tier models follow structure better than intent. Give them a schema, and they'll fill it in. Ask them to "be thorough," and they'll be thorough at the wrong things.
The GitHub Actions Workflow
The service is half the system. Here's the trigger:
name: AI Review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
issues: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract diff
id: diff
run: |
git diff origin/main...HEAD > /tmp/diff.txt
echo "size=$(wc -c < /tmp/diff.txt)" >> $GITHUB_OUTPUT
- name: Call review service
id: review
run: |
python - <<'EOF'
import httpx
diff = open('/tmp/diff.txt').read()
resp = httpx.post(
"${{ secrets.REVIEW_SERVICE_URL }}",
json={"diff": diff},
timeout=120
)
open('/tmp/review.json', 'w').write(resp.text)
EOF
- name: Post PR comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const review = JSON.parse(fs.readFileSync('/tmp/review.json', 'utf8'));
const sections = [];
if (review.critical?.length) {
sections.push('## 🔴 Critical\n' +
review.critical.map(c => `- **Line ${c.line}:** ${c.comment}`).join('\n'));
}
if (review.warnings?.length) {
sections.push('## 🟡 Warnings\n' +
review.warnings.map(c => `- **Line ${c.line}:** ${c.comment}`).join('\n'));
}
if (review.nitpicks?.length) {
sections.push('## 🔵 Nitpicks\n' +
review.nitpicks.map(c => `- **Line ${c.line}:** ${c.comment}`).join('\n'));
}
if (sections.length) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: sections.join('\n\n')
});
}
Two secrets to configure in the repo: REVIEW_SERVICE_URL and MODEL_API_KEY. The first points at your review service. The second is the model provider's key — or empty if you're using a local or free endpoint that doesn't need one.
What This Setup Is Designed to Catch
Here are the failure patterns this prompt template is built to flag — illustrative examples, not a benchmark:
-
A missing
awaiton an async database call — compiles fine, fails at runtime - An unclosed file handle in an error path — leaks only show up under load
- String interpolation into a SQL query instead of parameter binding
All three are real-world patterns. Your results will vary, but the shape of the output is consistent: structured, line-referenced, and categorized.
Expect noise too. About 30% of warnings will be false positives. That's fine. The cost of a false positive is a glance. The cost of a missed critical bug is an incident.
Failure Modes to Plan For
This setup will break. Here's how:
1. The model returns prose instead of JSON. The parse_review function handles this with a regex fallback, but the first time it happens, the Action will fail and the PR gets no comment. Fix: wrap the call in a retry that re-prompts with "Return JSON only."
2. Large diffs blow the context window. A 40-file refactor produces a diff too big for the prompt. The [:8000] cap means the model only reviews the first 8,000 characters. For big PRs, you'd want chunking — split the diff, review each chunk, merge the results.
3. The free server goes cold. If the server sleeps between requests, the first review takes 30+ seconds and the Action times out. Fix: set the Action timeout to 120 seconds and accept the latency.
Who Should Not Use This
Be honest about your constraints:
- Regulated codebases — PHI, PII, financial data. A free-tier model means your code leaves your boundary. Don't.
- Security-critical review — this is a first-pass filter, not an auditor. It catches sloppy bugs, not sophisticated attacks.
- Monorepos with massive diffs — the 8,000-character cap means the model sees a fraction of your changes. You'd need chunking, which is a real engineering project.
Where MonkeyCode Fits
The operator currently advertises free model access with a 10M-token allowance and a free server option. I can't verify those numbers from inside this article — check them before you commit — but the architecture above is provider-agnostic. The MODEL_ENDPOINT environment variable is the only thing that changes.
If you want to try this loop with a free tier, the setup cost is one hour. The failure cost is a few false-positive comments on a PR. That's a bet worth taking.
The Real Takeaway
A free-tier model won't replace your senior reviewer. It will catch the missing await that your senior reviewer is tired of looking for.
The architecture matters more than the model. A webhook, a structured prompt, and a parser that tolerates garbage output — that's the whole system. The model is interchangeable. The pipeline is the product.
Try it on a small repo. See what the first week catches. Then decide if it's worth upgrading.
Top comments (0)