Book a call
BUILD35mVERIFIED 2026-08-05 · CLAUDE CODE 2.1.221 · CODEX CLI 0.146.0 · ANTIGRAVITY CLI 1.1.10 · KIMI CODE CLI 0.31.1

An agent that reviews pull requests

A reviewer that runs on every pull request and posts at most five findings, each one anchored to a line and each one falsifiable — because the version that posts thirty gets muted.

What you end up with

4 artefacts, and only one of them has any substance:

  • .github/review-brief.md — our standard review brief, which communicates what qualifies as a finding and what never will (for example, it's not a finding if there's no concrete input for which the change breaks sth)

  • .github/findings-schema.json — the schema that describes the shape of the response we expect

  • scripts/pr-review.py — the script which calculates the diff, starts the run of the agent using your chosen harness, and then ignores most of its output to post only a single comment with the rest of it

  • .github/workflows/pr-review.yml — the workflow file, in which we define a proper trigger (PR created/synchronised/reopened) and set up the right permissions (the GITHUB_TOKEN with contents:read and pull-requests:write permissions)

It takes 35 minutes, and if you built the issue triager in the previous lesson, you can just base your workflow file on that one so only the brief and the filter are new. The majority of time goes into the "don't" part, not the "do" part — the models are already good at spotting things in a diff, what's much harder is picking only 3 out of 30 of them worth your time. That way you decide if this tool will still be useful here in a month

The bot people stop reading

A lot of people ask us why other companies drop their review bots. Well, we think it's mostly because how the engineering landscape has changed recently — writing the first draft of a piece of code is really cheap nowadays, understanding what a change actually does and what it'll cost you in the future is not. That's where the review comes into play, and that's what a review bot tries to address.

Here's an example of what we mean by this; say you create a PR with a small change in it. Then you run a generic "Review this PR" prompt on it — in theory you should get a list of findings, but in practice you get a long wall of text which is mostly the code itself, with a few ideas like:

  • This function is missing a docstring

  • You could extract a helper function for this piece of code

  • Consider renaming this variable

  • Can you write some tests for it?

And then at the very end there are two actual findings:

  • The type of this variable is not correct for this input

  • This line does not handle this edge case

It's not a big deal, there's no chaos, you just need to scroll a little, and after a while you stop scrolling and collapse it. And that's it — among the rest of this wall of text, those two actual findings go down too, the bot gets disabled, and the conclusion is that "AI review doesn't work". But it does work, we just need to build 3 things rather than prompt for them:

  • Defining what the agent can see

  • Defining what kind of information the agent can return

  • Defining how much of this information will reach you

Feed it the diff, not the repo

Let's start with defining what the agent can see. It needs to be the diff, not the entire repository. There are two potential pitfalls here.

The first one is a diff against main with two dots, which compares the branch with the current state of main, including the changes other people have made since you've created the branch. So the bot comments on code you haven't even touched.

BASH
git diff main HEAD

To address this, you need to use merge-base instead.

BASH
git diff --merge-base main HEAD

Another thing is that if you include everything in the diff, you will burn a lot of tokens to review a bunch of lockfiles, which will scatter your focus and won't return anything valuable anyway. That's why you need to exclude some files from the diff using pathspecs.

BASH
git diff --merge-base main HEAD -- \
  ':(exclude,glob)**/*.lock' \
  ':(exclude,glob)**/package-lock.json' \
  ':(exclude,glob)**/vendor/**' \
  ':(exclude,glob)**/*.generated.*'

What's more, when using pathspecs, you need to remember about one thing — if you use asterisks inside an exclude pathspec, the keyword glob is required (which we've used here), and it's really easy to forget about it. Without glob, that double asterisk only matches when there's a real directory in front of the filename — so a lockfile sitting in the root of your repository quietly slips through, which is the one case you cared about

With these exclusions, we reduced the diff in our test repository from 8 files to one.

The other thing is setting up an upper limit. After a certain point, every change is so big that it doesn't make sense to review it — if sb creates a PR with 3 thousand lines of changes, the best move is to tell them to split it, not to point out typos in it. That's why you need to make sure your script stops and informs you if a diff is too big.

But there's one exception to this. The agent needs to have access to the repository during the review — it might be that a particular hunk looks good on its own, but you can't see if sb returns sth from it; in such case, the bot needs to be able to open the file and check it. So make sure your script includes both the diff and the ability to open files (read-only)

Three of the five harnesses we've used have a documented flag for this. The other two don't — we've described which is which, and what to do instead, in the variant below.

Make it return data, not an essay

OK, so now we know what the agent can see. The next thing is defining what it can return.

What we mean here is that it should return structured data, not a wall of text. If you ask an LLM for a list of findings, it'll give you a list of findings. But if you ask it for a list of findings in JSON format, it'll give you a list of findings in JSON format — and this way we can further process it

The schema we've created defines the structure that each finding needs to have — it's very simple, and has the following fields:

  • file — the path to the file

  • line — the line number

  • severity — one of blocking, important, or nit

  • category — a free-form string, like security, bug, etc.

  • claim — the actual finding

  • breaks_when — what input this change breaks on

JSON
{
  "type": "object",
  "properties": {
    "findings": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "file": {"type": "string"},
          "line": {"type": "integer"},
          "severity": {"type": "string", "enum": ["blocking", "important", "nit"]},
          "category": {"type": "string"},
          "claim": {"type": "string"},
          "breaks_when": {"type": "string"}
        },
        "required": ["file", "line", "severity", "category", "claim", "breaks_when"],
        "additionalProperties": false
      }
    }
  },
  "required": ["findings"],
  "additionalProperties": false
}

This last field is the most important one — it's about naming the input, the order of arguments, or the state that leads to a bug. That way we can exclude things like "It'd be good to add a test for it", because there's no input we can use here; it's also better than saying "Be concise" — you can even go as far as making sure every finding has this field populated with a sensible value, and if it doesn't or is vague, drop it during the run of the script

This is what we call the brief:

  • It's a relatively short document, with a big part of it being a list of things you don't want to see

  • It basically says "Think as if you were the on-call engineer that got paged about this change", and then goes on to say that unless there's a concrete input for which the change breaks sth, it's not a finding; and even then, it's never good to communicate about:

    • Style

    • Naming

    • Formatting

    • The absence of comments

    • Any ideas around extracting smth into separate functions

    • General complaints about test coverage

    • Anything that a linter can find

It also says that if the bot finds sth, it needs to communicate it in a specific structure — JSON, as defined by the schema. And lastly, it explicitly says that an empty array is a valid response.

MARKDOWN
Review the diff below as the engineer who will be paged when it breaks.

Report only what you can state as a fact about this change: a specific input,
sequence or state that makes it behave wrong. If you cannot name the input that
breaks it, it is not a finding.

Do not report: style, naming, formatting, missing comments, "consider
extracting", test coverage in the abstract, or anything a linter already catches.

Severity is one of:
  blocking  - data loss, a security hole, or a broken contract someone depends on
  important - wrong under an input that will actually occur
  nit       - real but survivable

Answer with nothing but the JSON object described by the schema.
An empty findings array is a valid, expected answer.

The "it's valid" part here is really important. If you don't include it, the only success criterion you've defined for the model is finding sth

The severity floor and the comment budget

OK, so now we know what the agent can see and what it can return. The last thing is defining how much of its output will reach you.

There are two things here:

  • Minimum severity — don't include anything below "important" in the output

  • Maximum number of comments — five, with the most important ones at the beginning

These are two constants that we set in our script, not in the prompt. If the model gives you 30 findings, it'll return 3 out of them, and drop the remaining 27 on the Python level — which means you can inspect them, rather than having your attention span wasted on them; the script will also tell you how many it's dropped, so you'll have data to adjust the numbers later

We're aware that this way we lose real findings. But five actual findings with a silent 6th one is better than 30 findings that include five of them, because in the latter case people stop paying attention and then it doesn't surface anything at all — you don't want a tool that doesn't work well, you want a tool that works

Also, we know you might be tempted to say "tell me about maximum 5", but if you do this, you'll make the model responsible for ranking these findings, which will be invisible, not logged, and not easily adjustable without editing the prompt. It's better to have it be a simple line in our script that always does the same thing.

One more thing about severity — it's very important to remember that it's not an attribute of a piece of code, it's the model's judgment. Once it's an enum in a schema it stops looking like one. When we ran the same diff through two harnesses while writing this, one of them graded a falsy-default bug "important" and the other graded the exact same line a "nit". Neither of them is wrong

So set your minimum severity against your own harness and your own history, not by reading what somebody else set theirs to

Post it once, not once per push

The last thing is making sure we post a single updating comment instead of one per push. If we were to append new lines to an existing comment every time sb pushes, we'd end up with a log of the bot's history — instead, we post a single comment using gh pr comment with the edit-last and create-if-none commands:

  • edit-last targets the bot's previous comment

  • create-if-none is there for the first run

BASH
gh pr comment "$PR" --edit-last --create-if-none --body-file -

Together, they form an idempotent operation that costs less than any custom API solution.

Alternatively, you can use line-anchored inline comments, which are more readable but require more maintenance — for this, you'll need to use the pull request reviews REST endpoint with a "comments" array containing things like:

  • path

  • line

  • side

  • body

But there are two potential issues with the line-anchored approach:

  • The first one is that every line anchor is an assertion — it says "there's code on this line", so once the code moves, you own the re-anchoring and the de-duplication that edit-last was doing for you

  • The second thing is that if you set the event to REQUEST_CHANGES instead of COMMENT, the reviews endpoint will accept it — but you don't want to set up a bot that blocks merges

So if you ask us, start with the single updating comment and switch to inline once you're confident in what your agent returns

The fork problem

The last thing is forked PRs. This is where a reasonable-looking workflow becomes a security issue, so be careful here.

If you create a workflow that runs on forked PRs, GitHub will only give your GITHUB_TOKEN read-only permissions, and it won't share any of your secrets with the runner — so no comment posting, and no model API key. In theory, this means the entire workflow is doomed to fail if you run it on forked PRs.

But there's a potential solution. If you change the workflow's event to pull_request_target, it will run in the base repository's default branch context, so you'll get back your GITHUB_TOKEN and secrets — which means that you can post comments and use your model API key. But there's a catch — the code under review is on the fork, so if you check it out, you run some stranger's code with the GITHUB_TOKEN and your model API key; GitHub's own warning about this is not subtle: "Running untrusted code on the pull_request_target trigger may lead to security vulnerabilities. These vulnerabilities include cache poisoning and granting unintended access to write privileges or secrets."

There are two ways you can address this.

The first one is not reviewing PRs from forks — just add an "if" statement that compares the full name of the head repository with your own, and if they don't match, exit early. This is perfect if every single PR comes from a branch in your own repository, but make sure to check this, rather than assume it

YAML
if: github.event.pull_request.head.repo.full_name == github.repository

Another solution is a 2-workflow split — this is what GitHub Security Lab recommends:

  • Create a pull_request workflow that uses the read-only GITHUB_TOKEN and doesn't have access to any of your secrets. This one will run on forks, and will create an artifact with the bot's output

  • Then, create another workflow that runs on workflow_run events. In this one you can use the regular GITHUB_TOKEN and have access to your secrets — it'll download the artifact from the first workflow, and post a comment with its contents

This way, you never check out any code from forks in an environment where your secrets are present.

One last thing worth mentioning is what the GitHub Security Lab says about this: "Artifacts resulting from untrusted PR data are themselves untrusted and should be treated as such when handled in privileged contexts." This is especially important to keep in mind if you decide to go with the 2-workflow split — even though a diff is text rather than a binary, you don't want to do anything else with it than giving it to the model. Make sure your privileged workflow only reads the artifact and passes it on

This solution is more complex than the "if" statement, but it's worth it if you actually accept external contributions

Two lines that stop it costing money

OK, so these are two ways of dealing with forked PRs — let's have a look at the last cost controls.

The first one is setting up a concurrency group based on the PR number, with cancel-in-progress enabled. If sb creates a PR and pushes 5 times in 4 minutes to fix a typo, they'll trigger this workflow 5 times. With a concurrency group in place, the queue will already take care of this — but if you enable cancel-in-progress, it'll also terminate any running jobs, which are the ones using model tokens

YAML
concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

Otherwise, you'll spend your tokens to review a target that keeps changing, four times in a row. We'd say this is even more impactful than picking a particular model

The second thing is setting a job timeout to 10 minutes, and making sure your harness has its own budget or timeout flag. This varies between tools, so it's in the variant below

YAML
timeout-minutes: 10

The workflow

And that's it — here's what the resulting workflow looks like:

  • It runs on PR created/synchronised/reopened events

  • If the head repository is not the one you own, it does nothing

  • It uses an Ubuntu runner with a 10-minute timeout

  • It uses actions/checkout to check out the code with its entire history (so we can use merge-base)

  • It installs your chosen agent's harness

  • Lastly, it runs our script with the following environment variables:

    • BASE_REF — the name of the base branch

    • PR — the number of the PR

    • GH_TOKEN — the token with contents:read and pull-requests:write permissions

    • AGENT_CMD — the command that starts the agent's harness

YAML
name: pr-review

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  review:
    # Fork PRs get a read-only token and no secrets. Skip them until you build the split.
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # --merge-base needs the history, not just the tip

      # Install your harness here -- see the variant below.

      - run: python3 scripts/pr-review.py
        env:
          BASE_REF: origin/${{ github.event.pull_request.base.ref }}
          PR: ${{ github.event.pull_request.number }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          AGENT_CMD: ${{ vars.AGENT_CMD }}

The fetch-depth 0 is important here — by default, actions/checkout fetches a single commit, and with 0 it fetches the entire history. Leave it at the default and merge-base has nothing to compare against — it fails with fatal: no merge base found, which reads like a git problem rather than the config mistake it is

The AGENT_CMD is a repository variable so we can easily change the agent in the future without modifying the script (or revert the change if we want to try another one)

IN YOUR HARNESS

The call itself

claude -p reads the prompt on stdin, so it drops straight into AGENT_CMD

BASH
AGENT_CMD='claude -p \
  --output-format json \
  --json-schema "$(cat .github/findings-schema.json)" \
  --permission-mode dontAsk \
  --tools "Read,Grep,Glob" \
  --max-budget-usd 0.50 \
  --model sonnet'

In the beginning of the workflow we need to add an action that fetches the installer and then prepends its bin directory to PATH so it works, before the step that makes the call

YAML
- run: curl -fsSL https://claude.ai/install.sh | bash
- run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"

In case of the json-schema flag, if you provide a path to a file as a value it will be interpreted as JSON by the CLI, so it throws Error: --json-schema is not valid JSON: JSON Parse error before it even tries to run the agent. This is why we pass the content as a string instead.

If you use json output format, the parsed answer is in structured_output variable next to total_cost_usd. You can think of outputting this cost during the threshold calibration if you want.

The dontAsk permission mode is the CI layer of the control logic that doesn't allow the agent to do anything that's not in your allow rules and doesn't belong to the set of read-only commands so it can't modify the branch in a PR. Even more restrictive is setting tools to Read/Grep/Glob which excludes Bash and Edit commands as well as any network access.

Failure might sometimes look like success, if you're not authenticated for example it will return exit code 1 and is_error: true with subtype: "success" and result: "Not logged in · Please run /login". You should always check the exit code and is_error to determine that, don't rely on the subtype.

The most simple way of authenticating is setting ANTHROPIC_API_KEY env variable inside the job. Alternatively you can use --bare flag which bypasses hooks, plugins, MCP servers and CLAUDE.md discovery — it's a guarantee that it will work on every machine but at the same time it's a conscious decision if you want to ignore your repo's CLAUDE.md conventions in favour of the standard behaviour. It doesn't read OAuth credentials nor keychain so you'll need to set ANTHROPIC_API_KEY or an apiKeyHelper in the json you pass using --settings flag.

If what you're after is not a push-based bot but rather an on-demand in-depth review, you might want to try claude ultrareview (it's a cloud-hosted multi-agent PR review that outputs findings based on a branch or PR number). It's person-initiated so ignores your brief and threshold and budget. If this is what you're after, we'd recommend giving it a shot before implementing any of the above.

The call itself

codex exec with - reads the prompt from stdin, and it can write its final answer straight to a file:

BASH
AGENT_CMD='codex exec - \
  --sandbox read-only \
  --ephemeral \
  --output-schema .github/findings-schema.json \
  -o /tmp/findings.json'

Then point the script at that same file:

BASH
ANSWER_FILE=/tmp/findings.json

It's the cleanest of the five actually — -o emits only the last message so we don't need to do any unwrapping and there's no additional reasoning in it. In our local runs the file contained exactly the schema and nothing else.

But anyway, in the workflow we install @openai/codex globally first and authenticate using an API key from secrets like so:

YAML
- run: npm install -g @openai/codex
- run: printenv OPENAI_API_KEY | codex login --with-api-key
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Then, we need to use --sandbox read-only as it's the strictest of the three sandbox policies that Codex has (the other two are workspace-write and danger-full-access) and it's what stops the reviewer from editing the branch it was asked to look at. We also need --ephemeral — so the runner doesn't hoard sessions nobody ever resumes.

You can also use the built-in reviewer, here's how:

BASH
codex exec review --base main

It picks what to review with one of --base <BRANCH>, --commit <SHA> or --uncommitted.

It's useful if you want to use it manually but doesn't work for us as we aim to define what a finding is and how many of them a human sees in the form of a schema and threshold (in the code), not in the defaults of the tool.

The call itself

BASH
AGENT_CMD='cursor-agent -p \
  --output-format json \
  --mode plan \
  --trust'

Install it into the workflow using this curl script from cursor.com, then add the local bin folder to GITHUB_PATH; CURSOR_API_KEY set via env in the job.

YAML
- run: curl https://cursor.com/install -fsS | bash
- run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"

The script is setting up two names for the same binary — cursor-agent and agent, Cursor's docs uses the shorter one, both works, the longer one is just less likely to be mistaken for sth else in the workflow file.

Two things bite here, and both bite on the first CI run.

--trust. Fresh checkout is an unknown location for Cursor so when you run it with print mode it prints Workspace Trust Required message and suggests to use --trust, --yolo or -f flags; choose the first one as it's the only one that actually means skipping this prompt and the other ones mean allowing everything which isn't reasonable in workflow for code review.

Read-only. Using -p with cursor cli isn't enough to make it read-only, it has access to all tools (including write and shell), as per the docs; but to make sure the reviewer can't modify the branch you need to add --mode plan.

This version doesn't have a schema flag so the schema is just part of the brief and there's no way to enforce it.

The answer comes back as a string inside an envelope containing type, subtype, is_error and result fields — {"type": "result", "subtype": "success", "is_error": false, "result": "{\"findings\": …}", …}; the actual result is in the result field. The script extracts the result field before parsing, but also defines a fallback which finds the json object inside a string in case when the LLM prepends a sentence to the response.

What's more important though is that you should rather check if it's not an error based on the is_error field instead of assuming the run was successful if it returns a string that can be parsed into a json.

The call itself

BASH
AGENT_CMD='agy -p "$(cat)" \
  --output-format json \
  --json-schema .github/findings-schema.json \
  --print-timeout 15m'

That's how you define the command to set the output in JSON with a schema file and the print timeout to 15 minutes in agy; it takes the prompt as an argument rather than on stdin, hence the $(cat). If the diff is too big and exceeds the maximum length of arguments the shell can take, we can output the prompt to a file under $RUNNER_TEMP and point agy to that file (it will read from there).

It's also worth mentioning that in case of --json-schema it's actually a path to a file so you don't need $(cat) here, but the convention is opposite in Claude Code.

When it comes to the above command, the result is saved by agy in the structured_output key under the response envelope, with the status and number of tokens used.

Also, the --print-timeout option is set to 5 minutes by default which might not be sufficient if you want to review an actual diff, so make sure to increase it but do it keeping it below the timeout-minutes parameter of the job itself, so that if anything goes wrong, the CLI will raise an error rather than the runner being terminated.

The flag that comes to mind is not the right one — it's called --mode plan in the docs and it sounds like a read-only option but it's not. Antigravity's docs describe plan mode as investigating with read-only tools and then presenting "a structured execution outline for your approval before writing code". The reason this doesn't work here is that it's essentially a checkpoint for you to approve the plan, and there's no-one to approve anything in print mode.

Another one that might come to mind is --sandbox but that's not it either as the docs are explicit that sandbox is "an OS containment permission setting, not an execution mode", configured through /permissions rather than the mode flag.

So basically, there's no way to enforce read-only mode here, keep the brief asking for findings and never for fixes, so it has no reason to reach for a write, and rely on the fact that the GitHub-hosted runners are all new VMs, so any change you make in the checkout never gets to your branch as long as the workflow doesn't push it — and this one doesn't.

Before we move further, we need to sort authentication out. The docs say headless mode "uses your cached credentials" and that you should "authenticate once with an interactive agy session first", which is not optimal given that every GitHub-hosted runner is a new VM, and no CI-specific environment variable is documented. So make sure you can run a single agy -p on the real runner before going further. To be clear, the reason it worked locally while writing this lesson is that the credentials were already cached on that machine.

The call itself

Kimi needs a thin wrapper to be called from GitHub Actions as:

  • -p expects prompt to be passed via CLI argument, not piped on stdin

  • to get the print-mode output we need to extract it from the structure returned by Kimi

So let's create this wrapper at .github/kimi-review.sh and point AGENT_CMD to it. There will be a few steps:

  • we'll store the prompt in a temp file

  • run kimi in the print mode pointing to that file

  • pipe stream-json through jq to extract the very last assistant message from it

BASH
#!/usr/bin/env bash
set -euo pipefail

prompt="${RUNNER_TEMP:-/tmp}/review-prompt.md"
cat > "$prompt"

kimi -p "Read $prompt and answer exactly as it tells you to." \
  --output-format stream-json \
  | jq -rs 'map(select(.role == "assistant")) | last | .content'

We do this because we can't pass a big diff as a command-line argument due to limitations of shell so we go for storing it in a file (which is okay given Kimi can access it as part of the agent).

Also it's worth to know that output format supports text and stream-json, but not single-object JSON or JSON with schema option. That means you need to include the schema in the brief and then, in this setup, use jq to extract the very last assistant message from the NDJSON (Newline Delimited JSON). We'd recommend stream-json over text as in the latter Kimi interleaves the reasoning bullet points and 'To resume this session: kimi -r …' line with the actual answer so we would need to use regular expression to extract the JSON from it.

Kimi doesn't support running both --plan and -p, so if you try, it fails with error: Cannot combine --prompt with --plan.. That means that the read-only mode is not going to be available for what you're creating; it'll operate in whatever default mode the agent works in terms of permissions.

The same measures as for Antigravity apply here — we should make sure the brief asks for findings and never for fixes and that it's a fresh runner for every run so any changes are not persistent, provided there's no git push in the workflow.

Finally, you need to address the authentication part as Kimi uses device code flow which can't be completed on an ephemeral runner. So make sure you can run kimi -p on the real runner before creating a workflow around it.

Read it yourself for two weeks

This is the most important part of the whole build. Because at the end of the day, you don't know if it works until you see for yourself. And the only way to see for yourself is to read every single comment the bot posts for two weeks, and count how many times you acted on them

If most of these comments are things you'd usually ignore, it means the problem is with the brief, not with the model — add these things to your never-report list, or update the "breaks_when" section of the brief. Almost every bad review bot we've seen would become a good one if its brief were a paragraph longer

You can always adjust the budget and the severity threshold — but only if you're sure it's right and if you regularly hit this limit. You can promote one very specific thing to "blocking", for example hardcoded credentials or migrations missing rollbacks, but first check if you can't create a script that does it instead — it'll be faster and more cost-effective. And if after a month nobody reacts to it, disagrees with it, or fixes their code based on it, remove it altogether, because it's become scenery, and scenery in CI costs money

That's the evergreen version of the tool — a few comments per week, usually worth reading, none of them blocking. That's what we set out to achieve

THE FILEscripts/pr-review.py
PYTHON
#!/usr/bin/env python3
"""Review a pull request's diff with whatever agent AGENT_CMD names, then post the
findings that survive the severity floor and the comment budget as one sticky comment.

The agent is a black box here: it gets the brief plus the diff on stdin and is expected
to answer with the JSON object the brief describes. Everything downstream of that answer
is ordinary code, which is the point -- filtering, capping and de-duplication are things
you want to be able to read, not prompt for.
"""

import json
import os
import re
import subprocess
import sys

RANK = {"blocking": 3, "important": 2, "nit": 1}

BASE = os.environ.get("BASE_REF", "origin/main")
AGENT_CMD = os.environ.get("AGENT_CMD")
BRIEF = os.environ.get("BRIEF", ".github/review-brief.md")
FLOOR = os.environ.get("MIN_SEVERITY", "important")
BUDGET = int(os.environ.get("MAX_COMMENTS", "5"))
MAX_DIFF_LINES = int(os.environ.get("MAX_DIFF_LINES", "1500"))
PR = os.environ.get("PR")
ANSWER_FILE = os.environ.get("ANSWER_FILE")

# Paths where a finding is never worth a human's attention. Lockfiles and generated code
# are most of the diff and none of the review.
# The `glob` magic is load-bearing: without it `**/` needs a real directory in front of
# the name, so a lockfile in the repo root sails straight through.
BORING = [
    ":(exclude,glob)**/*.lock",
    ":(exclude,glob)**/package-lock.json",
    ":(exclude,glob)**/*.min.js",
    ":(exclude,glob)**/*.snap",
    ":(exclude,glob)**/vendor/**",
    ":(exclude,glob)**/*.generated.*",
]


def run(cmd, **kw):
    return subprocess.run(cmd, capture_output=True, text=True, check=True, **kw).stdout


def diff_against_base():
    """Merge-base: only what this branch added. A plain two-dot diff would also carry
    whatever landed on the base since the branch started, and the agent would spend the
    review commenting on other people's code."""
    out = run(["git", "diff", "--merge-base", BASE, "HEAD", "--"] + BORING)
    if len(out.splitlines()) > MAX_DIFF_LINES:
        files = run(["git", "diff", "--merge-base", BASE, "HEAD", "--stat", "--"] + BORING)
        return None, files
    return out, None


def extract(raw):
    """Harnesses disagree about what a structured answer looks like. Try the documented
    envelopes first, fall back to finding the object in a wall of prose."""
    try:
        doc = json.loads(raw)
    except json.JSONDecodeError:
        match = re.search(r"\{.*\}", raw, re.DOTALL)
        if not match:
            return None
        try:
            return json.loads(match.group(0))
        except json.JSONDecodeError:
            return None

    if isinstance(doc, dict):
        if isinstance(doc.get("structured_output"), dict):
            return doc["structured_output"]
        if "findings" in doc:
            return doc
        if isinstance(doc.get("result"), str):
            return extract(doc["result"])
    return None


def ask_agent(prompt):
    proc = subprocess.run(AGENT_CMD, shell=True, input=prompt, capture_output=True, text=True)
    if proc.returncode != 0:
        print(proc.stderr[-2000:], file=sys.stderr)
        sys.exit(f"agent exited {proc.returncode}")

    raw = open(ANSWER_FILE).read() if ANSWER_FILE else proc.stdout
    answer = extract(raw)
    if answer is None:
        print(raw[:2000], file=sys.stderr)
        sys.exit("agent did not return the schema")
    return answer


def render(kept, below_floor, over_budget):
    lines = ["<!-- pr-review-agent -->", "### Review"]
    if not kept:
        lines.append("Nothing above the floor. That is a real result, not a skipped run.")
    for f in kept:
        lines.append(f"\n**{f['severity']} - {f['category']}** in `{f['file']}:{f['line']}`")
        lines.append(f["claim"])
        lines.append(f"\n> {f['breaks_when']}")

    tail = []
    if below_floor:
        tail.append(f"{below_floor} below `{FLOOR}`")
    if over_budget:
        tail.append(f"{over_budget} over the budget of {BUDGET}")
    if tail:
        lines.append(f"\n---\n<sub>Suppressed: {', '.join(tail)}.</sub>")
    return "\n".join(lines)


def main():
    if not AGENT_CMD:
        sys.exit("set AGENT_CMD to the headless invocation for your harness")

    patch, stat = diff_against_base()
    if patch is None:
        print(f"diff is over {MAX_DIFF_LINES} lines -- split it:\n{stat}", file=sys.stderr)
        return
    if not patch.strip():
        print("nothing to review", file=sys.stderr)
        return

    findings = ask_agent(f"{open(BRIEF).read()}\n\n<diff>\n{patch}\n</diff>").get("findings", [])

    above = [f for f in findings if RANK.get(f.get("severity"), 0) >= RANK[FLOOR]]
    above.sort(key=lambda f: -RANK.get(f["severity"], 0))
    kept = above[:BUDGET]

    body = render(kept, len(findings) - len(above), len(above) - len(kept))
    print(f"{len(findings)} found, {len(kept)} posted", file=sys.stderr)

    if not PR:
        print(body)
        return

    subprocess.run(
        ["gh", "pr", "comment", PR, "--edit-last", "--create-if-none", "--body-file", "-"],
        input=body,
        text=True,
        check=True,
    )


if __name__ == "__main__":
    main()
j / k to move between lessons