PR reviewer as a GitHub Action: a typed review, and the PR's tests without secrets
A GitHub Action reviews each pull request with an agent and posts the review as a PR comment. The agent reads the diff and the files it changes, and the results of the PR's tests. Running the tests runs the PR's code, so the workflow keeps that code away from the agent's secrets:
- The review is typed. The job posts a verdict and one comment per finding, or says that the answer was not a valid review.
- Reads run, a push is denied. A policy answers each approval in the job: CI is read-only.
- The PR's code never runs with secrets. The PR's tests run in one workflow, with no secrets and a read-only token. The reviewer runs in another, from the default branch, and reads their output as text.
- No test results, no review. On your machine, without test results, the agent's ask for them stops the run, and the program prints what it asked.
- The job never hangs. It cancels the session when it ends, and a review that runs too long stops at 15 minutes.
Demonstrates: structured output, approvals answered by a policy, a timeout, and cancelling a session.
Run: in your repository, on your default branch, save the code blocks titled pr_reviewer.py,
in order, as .github/scripts/pr_reviewer.py, and the two workflows in .github/workflows/. Add
your Mistral API key as the repository secret MISTRAL_API_KEY (gh secret set MISTRAL_API_KEY,
or Settings › Secrets and variables › Actions). Pull requests from forks are reviewed too.
To try it on your machine first, run it on a merged pull request of
more-itertools, which validates the
chunk size in chunked_even. With the SDK installed and MISTRAL_API_KEY set
(setup), and no PR_NUMBER, the program prints what the job would
post. Then write the test results, as the tests workflow does, and run it again:
git clone https://github.com/more-itertools/more-itertools && cd more-itertools
git fetch origin pull/1342/head && git checkout FETCH_HEAD
BASE=4aeb433 python path/to/pr_reviewer.py
python -m unittest tests.test_more > test-results.txt 2>&1
BASE=4aeb433 python path/to/pr_reviewer.pyThe workflows
The tests run on each pull request, in a workflow that has no secrets:
name: Agent tests
# The PR's own copy of this workflow runs: with no secrets and a read-only token.
on: pull_request
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: "3.12"
# Your project: add your test dependencies here. A failed install is in the output too.
- run: |
mkdir -p "$RUNNER_TEMP/out" && echo "$PR_NUMBER" > "$RUNNER_TEMP/out/pr-number"
pip install pytest -e . > "$RUNNER_TEMP/out/test-results.txt" 2>&1 &&
python -m pytest -q >> "$RUNNER_TEMP/out/test-results.txt" 2>&1 || true
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
- uses: actions/upload-artifact@v7
with:
name: test-results
path: ${{ runner.temp }}/outpull_requestruns the PR's own copy of this workflow, on the PR's code. It references no secret, and its token can only read. A pull request from a fork gets no secrets: in a private repository, keep "Send secrets to workflows from pull requests" and "Send write tokens to workflows from pull requests" off. GitHub can ask a maintainer to approve runs from forks first (Approving workflow runs from forks).- The run keeps the tests' output and the PR's number as an artifact, a failed install
included.
pip install -e .installs your project: add your test dependencies, such as-e ".[test]".
When that run succeeds, the review runs, from the default branch, with the secret:
name: Agent review
on:
# After each run of Agent tests on a pull request. This workflow and pr_reviewer.py come from the
# default branch, with the repository's secrets; no code from the pull request runs here.
workflow_run:
workflows: [Agent tests]
types: [completed]
permissions: {}
jobs:
review:
if: >-
github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: read # the artifact of the tests run
contents: read
pull-requests: write
env:
GH_TOKEN: ${{ github.token }}
HEAD: ${{ github.event.workflow_run.head_sha }}
steps:
- uses: actions/checkout@v7 # the default branch
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: test-results
path: ${{ runner.temp }}/tests
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
# The pull request wrote the artifact: its number must name the pull request whose head was tested.
- run: |
number=$(head -c 20 "$RUNNER_TEMP/tests/pr-number")
[[ "$number" =~ ^[0-9]+$ ]] || { echo "The artifact's PR number is not a number."; exit 1; }
read -r pr_head base < <(gh pr view "$number" --json headRefOid,baseRefOid --jq '.headRefOid + " " + .baseRefOid')
[ "$pr_head" = "$HEAD" ] || { echo "PR $number's head is not the tested commit: a newer push gets its own run."; exit 1; }
git fetch --no-tags origin "refs/pull/$number/head" # the PR's commits, read as text only
git cat-file -e "$HEAD^{commit}" || { echo "The tested commit is gone: a newer push gets its own run."; exit 1; }
echo "PR_NUMBER=$number" >> "$GITHUB_ENV"
echo "BASE=$base" >> "$GITHUB_ENV"
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip install "mistralai-agents==1.1.0"
- run: python .github/scripts/pr_reviewer.py
env:
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TEST_RESULTS: ${{ runner.temp }}/tests/test-results.txtworkflow_runruns this workflow from your default branch, after each tests run. The pull request cannot change it, and no code from the pull request runs here. A tests run that times out or is cancelled gets no review: its log says why.- The artifact comes from the PR's run, so the job reads it as data: the PR's number must be a number, and that pull request's head must be the commit that was tested. Otherwise the job stops: a newer push gets its own run.
- The review job fetches the PR's commits to read them as text, and passes the test results to the program.
BASEis the base branch's commit.BASE...HEADdiffs from where the pull request split from it, so the agent reads only the PR's changes.permissionsgives the job only what it needs: read the tests run's artifact and the code, and post a review.runs-onstays on GitHub-hosted runners. On a self-hosted runner that is not ephemeral, the tests' code could stay on the machine and reach the review's secret.
The PR's author writes the diff and the test output that the agent reads, so they can steer what the review says. The job posts a comment, never an approval. Each push to a pull request runs the tests and a new review, which calls the model with your key and posts a new comment.
The agent and its tools
read_file reads the PR's head with git show. test_results reads the tests' output.
push_fix stands for a tool CI must not run: the policy denies it.
import asyncio
import os
import subprocess
from pathlib import Path
from typing import Literal
from pydantic import BaseModel
from mistralai import agents
class Review(BaseModel):
verdict: Literal["approve", "request_changes"]
comments: list[str]
def git(*args: str) -> str:
return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout
def test_results_file() -> Path:
return Path(os.environ.get("TEST_RESULTS", "test-results.txt"))
@agents.tool(annotations=agents.ToolAnnotations(read_only_hint=True, destructive_hint=False))
def read_file(path: str) -> str:
"""Read a file of the repository at the PR's head."""
shown = subprocess.run(["git", "show", f"{os.environ.get('HEAD', 'HEAD')}:{path}"], capture_output=True, text=True)
if shown.returncode: # such as a path the PR deletes: the model reads git's message
raise agents.ToolError(shown.stderr.strip())
return shown.stdout
@agents.tool
def test_results() -> str:
"""The output of the PR's tests, from the run that ran them."""
return test_results_file().read_text()[-3000:]
@agents.tool
def push_fix(path: str, content: str) -> str:
"""Commit a fix to a file and push it to the PR's branch."""
with open(path, "w") as file:
file.write(content)
git("commit", "--quiet", f"--message=Fix {path}", "--", path)
git("push", "--quiet", "origin", f"HEAD:refs/heads/{os.environ['GITHUB_HEAD_REF']}")
return "pushed"
reviewer = agents.Agent(
model="mistral-medium-latest",
name="pr-reviewer",
instructions=(
"You review pull requests of a Python repository. Read each file the diff changes with "
"read_file. When the diff changes code or tests, read the test results with test_results. "
"If you see a typo, fix it with push_fix. Then give your verdict, and one comment per "
"finding that names the file and the function."
),
tools=[read_file, test_results, push_fix],
output_type=Review, # the model is asked for a Review
permission_mode="ask", # the default "auto" asks nothing, so no handler runs
)output_type=Review asks the model for a Review. permission_mode="ask" sends every tool call
to the approval handler; the default, "auto", asks nothing.
The policy
The job answers each approval itself, from the tool's annotations and whether the test results are there:
def ci_policy(
ctx: agents.ApprovalCallbackContext, request: agents.ApprovalRequest
) -> agents.AllowApproval | agents.DenyApproval | agents.KeepCallbackOpen:
if request.tool.annotations.read_only_hint:
return agents.AllowApproval()
if request.tool.id == "client.test_results":
if test_results_file().exists():
return agents.AllowApproval() # the tests run wrote them
return agents.KeepCallbackOpen() # no test results: the turn comes back Blocked
return agents.DenyApproval() # CI is read-only: no pushKeepCallbackOpen() answers nothing: the approval stays open, and the turn comes back Blocked.
Review one pull request
review_pr runs the review as one turn and turns every outcome into the text the job posts:
async def review_pr(reviewer: agents.Agent, diff: str) -> str:
session = await reviewer.start_session(on_approval=ci_policy)
try:
state = await session.run(diff, timeout=900)
finally:
await session.cancel() # the CI job ends here: a turn still running or waiting stops with it
match state:
case agents.Completed(output=agents.InvalidOutput(error=error)):
return f"Invalid review: {error}" # a value: run the review again, in a new job
case agents.Completed(output=Review() as review):
return "\n".join([f"Verdict: {review.verdict}", *(f"- {comment}" for comment in review.comments)])
case agents.Blocked(callbacks=callbacks):
asked = ", ".join(callback.title for callback in callbacks)
return f"Stopped: {asked} No test results: write them to {test_results_file()}."
case agents.Running():
return "Stopped: the review took more than 15 minutes."
case agents.Failed(error=error):
return f"Failed: {error.message}"
case other: # Interrupted, or a turn that ended without a review
return f"No review: the turn ended as {type(other).__name__}."
def post(body: str) -> None:
if number := os.environ.get("PR_NUMBER"):
subprocess.run(["gh", "pr", "review", number, "--comment", "--body", body], check=True)
else:
print(body) # a run outside the workflow posts nothing
async def main() -> None:
diff = git("diff", f"{os.environ.get('BASE', 'origin/main')}...{os.environ.get('HEAD', 'HEAD')}")
post(await review_pr(reviewer, diff))
if __name__ == "__main__":
asyncio.run(main())- A review is posted as a verdict and one line per comment.
- An invalid review comes back as a value,
InvalidOutput, not an exception. - A turn that waits for the test results comes back
Blocked, with the approval's title. The job posts it, with where to write the test results. - A review that runs past 15 minutes comes back
Running. - A failed turn posts its error's message. Any other end, such as a turn cut short with no review, posts the record's name.
session.cancel() runs in every case, so the job leaves no session waiting.
Output
On more-itertools pull request 1342, without test results, the agent asks for them and the run stops:
With the test results written as in the commands above, the program prints its review. One run:
chunked_even in more_itertools/more.py correctly handle edge cases for n < 0 and n == 0. The added validation ensures that a ValueError is raised for negative n, and an empty iterator is returned for n == 0.tests/test_more.py (e.g., test_zero_chunk_size and test_negative_chunk_size) properly validate these edge cases, including ensuring the source iterator remains unconsumed when n == 0 or n < 0.A run can differ: one of four runs on this pull request requested changes with a wrong finding.
Test it
The tests run with a scripted model, so they need no API key. They check the policy's answers: a read is allowed and a push is denied, and the file stays unchanged; an answer that is not a review comes back as a value; without test results the run stops, and the session is cancelled; with them, reading the test results is allowed:
"""The tests of the PR reviewer example: a scripted model, no API key.
Run them beside pr_reviewer.py: pytest -o asyncio_mode=auto test_pr_reviewer.py
"""
import json
from pathlib import Path
import pr_reviewer
import pytest
from mistralai import agents
def review(verdict: str, comment: str) -> agents.testing.ReplyStep:
return agents.testing.reply(json.dumps({"verdict": verdict, "comments": [comment]}))
def tool(name: str, arguments: dict[str, str]) -> agents.testing.ToolCallStep:
# The model calls each tool from code, as a real model does.
return agents.testing.run_typescript(f"return await tools.client.{name}({json.dumps(arguments)});")
def in_scratch_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[tuple[str, str]]:
"""Run in an empty folder, and record each answer of the policy."""
monkeypatch.chdir(tmp_path) # push_fix would write here, and the test results are read here
monkeypatch.delenv("TEST_RESULTS", raising=False)
answers = []
policy = pr_reviewer.ci_policy
def recording_policy(
ctx: agents.ApprovalCallbackContext, request: agents.ApprovalRequest
) -> agents.AllowApproval | agents.DenyApproval | agents.KeepCallbackOpen:
answer = policy(ctx, request)
answers.append((request.tool.id, type(answer).__name__))
return answer
monkeypatch.setattr(pr_reviewer, "ci_policy", recording_policy)
return answers
async def test_reads_run_and_a_push_is_denied(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
answers = in_scratch_folder(monkeypatch, tmp_path)
(tmp_path / "more.py").write_text("original\n")
model = agents.testing.ScriptedModel(
[
tool("read_file", {"path": "more.py"}),
tool("push_fix", {"path": "more.py", "content": "# fixed"}),
review("request_changes", "more.py, chunked_even: n=0 loops forever."),
]
)
result = await pr_reviewer.review_pr(pr_reviewer.reviewer.override(model=model), "diff")
model.assert_consumed()
assert result == "Verdict: request_changes\n- more.py, chunked_even: n=0 loops forever."
assert answers == [("client.read_file", "AllowApproval"), ("client.push_fix", "DenyApproval")]
assert (tmp_path / "more.py").read_text() == "original\n" # push_fix never ran
async def test_an_answer_that_is_not_a_review_is_a_value() -> None:
model = agents.testing.ScriptedModel([agents.testing.reply("Looks good to me!")])
result = await pr_reviewer.review_pr(pr_reviewer.reviewer.override(model=model), "diff")
assert result.startswith("Invalid review: ")
async def test_without_test_results_the_run_stops_and_leaves_no_session(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
answers = in_scratch_folder(monkeypatch, tmp_path)
model = agents.testing.ScriptedModel([tool("test_results", {})])
cancelled = []
cancel = agents.Session.cancel
async def record_cancel(session: agents.Session) -> None:
cancelled.append(session.id)
await cancel(session)
monkeypatch.setattr(agents.Session, "cancel", record_cancel)
result = await pr_reviewer.review_pr(pr_reviewer.reviewer.override(model=model), "diff")
assert result.startswith("Stopped: ")
assert result.endswith("No test results: write them to test-results.txt.")
assert answers == [("client.test_results", "KeepCallbackOpen")]
assert len(cancelled) == 1 # the job leaves no session waiting
async def test_with_test_results_the_agent_reads_them(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
answers = in_scratch_folder(monkeypatch, tmp_path)
(tmp_path / "test-results.txt").write_text("42 passed in 1.20s\n") # what the tests run wrote
model = agents.testing.ScriptedModel(
[
tool("test_results", {}),
review("approve", "All tests pass."),
]
)
result = await pr_reviewer.review_pr(pr_reviewer.reviewer.override(model=model), "diff")
assert result == "Verdict: approve\n- All tests pass."
assert answers == [("client.test_results", "AllowApproval")]Run them beside pr_reviewer.py with pytest -o asyncio_mode=auto test_pr_reviewer.py.
Next steps
- Structured output:
output_type=andInvalidOutput. - Approvals: policies,
KeepCallbackOpen(), and answering by id. - Wait with a timeout: what
timeout=stops, and what it does not.