DEV Community

Charlie Xu
Charlie Xu

Posted on

Your PRs Deserve a Second Reviewer That Costs Nothing: A Webhook Architecture

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
Enter fullscreen mode Exit fullscreen mode

Three moving parts:

  1. A FastAPI service that receives diffs, builds a review prompt, and calls the model
  2. A GitHub Actions workflow that extracts the diff and posts the review back
  3. 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": []}
Enter fullscreen mode Exit fullscreen mode

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')
              });
            }
Enter fullscreen mode Exit fullscreen mode

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 await on 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)