Skip to content

PR Review

Code review is a natural fit for AI coding agents — they already understand codebases, can read diffs, and can reason about correctness. But building a good review mode requires more than just “send the diff to the model.” The agent needs to compute the right merge-base to know which changes are actually new, construct a system prompt that produces actionable findings (not vague suggestions), and return structured output that can be rendered inline in a review UI.

The three reference implementations reflect different philosophies: Aider has no review mode at all, Codex provides a full review pipeline with structured JSON output and priority-tagged findings, and OpenCode integrates with GitHub Actions for event-driven PR review but delegates the actual review to its general-purpose agent loop.


Aider has no PR review or code review feature.

The closest functionality:

  • /add <file> (commands.py:799): Adds files to the chat context so the LLM can analyze them. The docstring says “Add files to the chat so aider can edit them or review them in detail” — but this is just context loading, not a review workflow.
  • /diff (commands.py:657): Shows the git diff of pending changes. Display only, no review.
  • /git (commands.py): Passthrough to arbitrary git commands.

There is no GitHub/GitLab API integration, no --review flag, no diff-based prompting, and no structured review output. To use Aider for code review, you would manually add files and ask questions — it is a manual, conversational process.


Codex has a dedicated review subcommand that computes diff context from git, constructs a specialized review system prompt, spawns an isolated review task, and returns structured JSON with prioritized findings and file-level code locations.

The ReviewArgs struct at exec/src/cli.rs:208-241 defines four mutually exclusive review targets:

pub struct ReviewArgs {
/// Review staged, unstaged, and untracked changes.
#[arg(long = "uncommitted")]
pub uncommitted: bool,
/// Review changes against the given base branch.
#[arg(long = "base", value_name = "BRANCH")]
pub base: Option<String>,
/// Review the changes introduced by a commit.
#[arg(long = "commit", value_name = "SHA")]
pub commit: Option<String>,
/// Optional commit title to display in the review summary.
#[arg(long = "title", value_name = "TITLE")]
pub commit_title: Option<String>,
/// Custom review instructions. If `-` is used, read from stdin.
#[arg(value_name = "PROMPT")]
pub prompt: Option<String>,
}

Usage examples:

  • codex review --uncommitted — Review working tree changes
  • codex review --base main — Review current branch against main
  • codex review --commit abc123 --title "Add caching" — Review a specific commit
  • codex review "Check for SQL injection" — Custom review instructions

The handler at cli/src/main.rs:580-588 routes the subcommand:

Some(Subcommand::Review(review_args)) => {
let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?;
exec_cli.command = Some(ExecCommand::Review(review_args));
prepend_config_flags(&mut exec_cli.config_overrides, root_config_overrides.clone());
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
}

Review reuses the exec pipeline — it is not a separate binary or mode. Config overrides (model, sandbox policy, etc.) apply normally.

The ReviewTarget enum at protocol/src/protocol.rs:2015-2046 is a JSON-tagged union:

#[serde(tag = "type", rename_all = "camelCase")]
pub enum ReviewTarget {
UncommittedChanges,
BaseBranch { branch: String },
Commit { sha: String, title: Option<String> },
Custom { instructions: String },
}
pub struct ReviewRequest {
pub target: ReviewTarget,
pub user_facing_hint: Option<String>,
}

The user_facing_hint is a short string displayed in the TUI while the review runs (e.g., "changes against 'main'", "commit abc1234: Add caching").

build_review_request() at exec/src/lib.rs:826-854 converts CLI args to a ReviewRequest:

fn build_review_request(args: ReviewArgs) -> anyhow::Result<ReviewRequest> {
let target = if args.uncommitted {
ReviewTarget::UncommittedChanges
} else if let Some(branch) = args.base {
ReviewTarget::BaseBranch { branch }
} else if let Some(sha) = args.commit {
ReviewTarget::Commit { sha, title: args.commit_title }
} else if let Some(prompt_arg) = args.prompt {
let prompt = resolve_prompt(Some(prompt_arg)).trim().to_string();
if prompt.is_empty() {
anyhow::bail!("Review prompt cannot be empty");
}
ReviewTarget::Custom { instructions: prompt }
} else {
anyhow::bail!(
"Specify --uncommitted, --base, --commit, or provide custom review instructions"
);
};
Ok(ReviewRequest { target, user_facing_hint: None })
}

Exactly one target must be specified — no default, no fallback. The resolve_prompt() function supports reading from stdin when the prompt is "-".

For BaseBranch reviews, the prompt construction calls merge_base_with_head() from utils/git/src/branch.rs:15-47. This function:

  1. Resolves the current HEAD via git rev-parse HEAD
  2. Resolves the target branch ref via git rev-parse --verify <branch>
  3. Checks if the remote upstream is ahead via resolve_upstream_if_remote_ahead() (branch.rs:68-117) — this runs git rev-list --left-right --count <branch>...<upstream> and returns the upstream ref if the remote has more commits
  4. Computes git merge-base <HEAD> <preferred_ref> where preferred_ref is the upstream (if ahead) or local branch

The upstream preference is important: if someone pushed to origin/main after your branch diverged, the merge-base against origin/main gives you the correct diff, not the stale local main.

If merge_base_with_head() succeeds, the prompt includes the concrete SHA:

Review the code changes against the base branch 'main'.
The merge base commit for this comparison is abc123def.
Run `git diff abc123def` to inspect the changes relative to main.
Provide prioritized, actionable findings.

If it fails (no common ancestor, detached HEAD, unresolvable branch), a fallback prompt instructs the model to compute the merge-base itself:

Review the code changes against the base branch 'main'.
Start by finding the merge diff between the current branch and main's
upstream e.g. (`git merge-base HEAD "$(git rev-parse --abbrev-ref "main@{upstream}")"`)...

This fallback at review_prompts.rs:15 is verbose but robust — the model has shell access and can run the git commands itself.

resolve_review_request() at review_prompts.rs:22-37 converts a ReviewRequest into a ResolvedReviewRequest containing the final prompt string and user-facing hint. The four target-specific prompt templates:

  • UncommittedChanges (review_prompts.rs:13): "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings."
  • BaseBranch (review_prompts.rs:16): Template with {baseBranch} and {mergeBaseSha} placeholders, or fallback
  • Commit with title (review_prompts.rs:18): "Review the code changes introduced by commit {sha} (\"{title}\"). Provide prioritized, actionable findings."
  • Commit without title (review_prompts.rs:19-20): Same but without title
  • Custom (review_prompts.rs:60-66): User’s raw instructions, trimmed, with empty check

The core review rubric lives in core/review_prompt.md (88 lines), included at compile time via include_str! at client_common.rs:18:

pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md");

This is injected as the system/developer message for the review task. It contains:

Bug Identification Criteria (8 rules):

  1. Must meaningfully impact accuracy, performance, security, or maintainability
  2. Must be discrete and actionable
  3. Must match the codebase’s existing rigor level (don’t demand input validation in a personal script repo)
  4. Must be introduced in the diff (pre-existing bugs are not flagged)
  5. Author would likely fix it if made aware
  6. Cannot rely on unstated assumptions
  7. Must identify provably affected code (no speculation about “may disrupt”)
  8. Must not be an intentional change

Comment Guidelines (8 rules):

  1. Clear about why it is a bug
  2. Communicate severity accurately (no inflation)
  3. Brief — one paragraph max, code snippets max 3 lines
  4. Cite files/lines/functions
  5. Explicitly state triggering scenarios/environments
  6. Matter-of-fact tone — no flattery, no accusation
  7. Immediately graspable without close reading
  8. No “Great job…” or “Thanks for…”

Priority Levels:

  • [P0]: Blocking release/operations. Drop everything. Only for universal issues with no assumption dependencies.
  • [P1]: Urgent. Next cycle.
  • [P2]: Normal. Fix eventually.
  • [P3]: Low. Nice to have.

Output Schema:

{
"findings": [
{
"title": "<80 chars, imperative, priority-tagged>",
"body": "<Markdown, cite files/lines>",
"confidence_score": 0.0-1.0,
"priority": 0-3,
"code_location": {
"absolute_file_path": "<path>",
"line_range": {"start": 1, "end": 5}
}
}
],
"overall_correctness": "patch is correct" | "patch is incorrect",
"overall_explanation": "<1-3 sentences>",
"overall_confidence_score": 0.0-1.0
}

Additional constraints: no markdown fences around the JSON, code_location is required, line ranges must be as short as possible (max 5-10 lines), location must overlap with the diff, and suggestion blocks must preserve exact whitespace.

The review() handler at codex.rs:4092-4124 resolves the request, then spawns a child task via spawn_review_thread() at codex.rs:4128-4248:

  1. Model selection: Uses config.review_model if set, otherwise falls back to the session’s default model. This lets users configure a cheaper/faster model for reviews.
  2. Feature disabling: Web search is disabled for reviews (review_features.disable(Feature::WebSearchRequest).disable(Feature::WebSearchCached)).
  3. Context isolation: The review turn context clears developer_instructions and user_instructions to None — the review operates with a clean prompt, not inheriting the parent session’s instruction set.
  4. Input seeding: The synthesized review prompt is submitted as the initial user message. No parent session history is included.
  5. Git enrichment: spawn_git_enrichment_task() runs asynchronously to collect branch/commit metadata.
  6. Event emission: EnteredReviewMode(review_request) is sent to the client.

The task runs within the standard agent loop — the model can use tools (shell commands for git diff, file reads) to inspect the codebase. When it completes:

  1. The JSON output is parsed into ReviewOutputEvent (protocol.rs:2048-2090)
  2. ExitedReviewMode event is emitted with the parsed findings
  3. TurnComplete event signals the review is done
  4. The review history is recorded in the parent session’s rollout
// protocol/src/protocol.rs:2048-2090
pub struct ReviewOutputEvent {
pub findings: Vec<ReviewFinding>,
pub overall_correctness: String,
pub overall_explanation: String,
pub overall_confidence_score: f32,
}
pub struct ReviewFinding {
pub title: String,
pub body: String,
pub confidence_score: f32,
pub priority: i32,
pub code_location: ReviewCodeLocation,
}
pub struct ReviewCodeLocation {
pub absolute_file_path: PathBuf,
pub line_range: ReviewLineRange,
}
pub struct ReviewLineRange {
pub start: u32,
pub end: u32,
}

These types are JsonSchema-derived and TS-exported — they serve both the Rust core and any TypeScript client (TUI, web UI, MCP consumer).


OpenCode approaches PR review differently from Codex. Instead of a dedicated review mode with structured output, it provides two complementary features: a CLI pr command for interactive review, and a GitHub Actions integration for automated, event-driven PR review.

Defined at cli/cmd/pr.ts:1-112:

export const PrCommand = cmd({
command: "pr <number>",
describe: "fetch and checkout a GitHub PR branch, then run opencode",
builder: (yargs) => yargs.positional("number", {
type: "number",
describe: "PR number to checkout",
demandOption: true,
}),
async handler(args) { /* ... */ }
})

The handler:

  1. Validates the current directory is a git repo
  2. Runs gh pr checkout <number> --branch pr/<number> --force to fetch and check out the PR branch
  3. Fetches PR metadata via gh pr view <number> --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body
  4. Fork handling: If isCrossRepository is true, adds the fork as a git remote (git remote add <owner> https://github.com/<owner>/<repo>.git) and sets the upstream branch so pushes go to the fork
  5. Session import: Scans the PR body for OpenCode session links (https://opncd.ai/s/<id>) and imports them via opencode import <url> — this lets reviewers resume the author’s context
  6. Launches the interactive TUI with the imported session ID if available

This is a convenience command, not a review mode. It sets up the git context and launches the normal agent. There is no review-specific system prompt, no structured output, and no priority tagging. The user drives the review conversationally.

The more substantial review path is through GitHub Actions. The action configuration (github/action.yml) triggers on:

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]

The handler at cli/cmd/github.ts differentiates between event types and constructs context accordingly.

Review Comment Context Extraction (github.ts:694-709):

When the trigger is pull_request_review_comment, the handler extracts:

  • file: The file being reviewed
  • diffHunk: The actual diff context around the comment
  • line / originalLine: Line numbers in the new and old versions
  • position: Position within the diff
  • commitId / originalCommitId: Commit SHAs

Prompt Construction (github.ts:731-750):

Three scenarios for PR review comments:

  1. Bare mention (e.g., /opencode with no further text):

    Review this code change and suggest improvements for the commented lines:
    File: src/lib.rs
    Lines: 42
    <diff hunk>
  2. Mention with instructions (e.g., /opencode check for race conditions):

    check for race conditions
    Context: You are reviewing a comment on file "src/lib.rs" at line 42.
    Diff context:
    <diff hunk>
  3. General PR comment (not a review comment): The comment body is used as-is, or "Summarize this thread" if bare.

Rich PR Context (github.ts:1380-1524):

A comprehensive GraphQL query fetches:

  • PR metadata: title, body, author, state, base/head branches
  • Change statistics: additions, deletions, commit count
  • File changes: paths, change types, line counts
  • All PR comments (excluding the trigger comment)
  • Existing reviews with state (APPROVED/CHANGES_REQUESTED/COMMENTED) and inline review comments

This is formatted into an XML-structured prompt block:

<github_action_context>
You are running as a GitHub Action...
</github_action_context>
<pull_request>
Title: [title]
Body: [description]
Author: [author]
Base Branch: [base] → Head Branch: [head]
...
<pull_request_changed_files>
- src/lib.rs (MODIFIED) +20/-5
</pull_request_changed_files>
<pull_request_comments>
- user at timestamp: comment text
</pull_request_comments>
<pull_request_reviews>
- reviewer at timestamp:
- Review body: ...
- Comments:
- file.ts:42: inline comment
</pull_request_reviews>
</pull_request>

Image Handling (github.ts:752-805):

The system extracts images from GitHub comments (markdown ![Image](url) and HTML <img> tags), downloads and base64-encodes them, and passes them to the LLM as file references. This supports multimodal review comments where reviewers include screenshots.

  1. No structured output: OpenCode’s review produces free-text responses, not typed JSON with priority levels and code locations
  2. No dedicated review system prompt: The general agent loop handles review, with context injected as a user message
  3. Event-driven: Reviews are triggered by GitHub webhook events, not a CLI subcommand
  4. Full agent capabilities: The model can edit files, run commands, and push changes — it is not constrained to read-only review
  5. Session continuity: Session import from PR body lets reviewers pick up the author’s full conversation context

The naive approach (git diff main..HEAD) gives wrong results when main has moved forward since the branch was created. The correct approach is git merge-base followed by git diff <merge-base>..HEAD. Codex handles this with a multi-step process that also checks if the remote upstream is ahead of the local branch. If you skip this, the review will flag pre-existing code as “bugs introduced in the PR.”

When merge_base_with_head() fails (no common ancestor, detached HEAD), Codex’s fallback prompt tells the model to compute the merge-base itself using shell commands. This works because the model has tool access, but it adds a tool-call round trip and can fail if the model constructs the git command incorrectly. A more robust approach would be to compute the merge-base in Rust and hard-fail if it cannot be determined.

Review System Prompts Need Careful Calibration

Section titled “Review System Prompts Need Careful Calibration”

Codex’s 88-line review prompt is the result of iteration. Key lessons baked into it:

  • “Pre-existing bugs should not be flagged” prevents the review from becoming a general audit
  • “Must match the codebase’s existing rigor level” prevents pedantic findings in casual repos
  • “Must identify provably affected code” prevents speculative “this might break X” comments
  • “No flattery” prevents the model from padding output with “Great work!” noise

Without these constraints, models tend to produce verbose, low-signal reviews that reviewers learn to ignore.

Codex requests raw JSON (no markdown fences) and parses it into ReviewOutputEvent. If the model wraps the JSON in ```json ``` fences, adds explanatory prose before/after, or produces malformed JSON, parsing fails silently. The review prompt explicitly says “Do not wrap the JSON in markdown fences or extra prose” but models do not always comply, especially with long reviews where the model loses track of formatting constraints.

Codex disables web search but does not restrict the review task to read-only sandbox mode. The model retains shell access and could theoretically modify files during a review. This is arguably a feature (the model can run git diff itself) but creates a trust question: should a review operation have write access? OpenCode’s GitHub Actions integration goes further — the model can push changes. This blurs the line between “review” and “auto-fix.”

The code_location in review findings requires absolute_file_path and line_range. The model must infer these from the diff context. For large PRs touching many files, the model’s line-range accuracy degrades — it may cite the wrong file or offset the line numbers. Codex constrains this with “line ranges must be as short as possible” and “location must overlap with the diff”, but validation of these constraints happens at the prompt level, not in code.

Codex supports config.review_model to use a different model for reviews. This is important because review and coding have different characteristics: reviews benefit from longer reasoning (reasoning models), while coding benefits from fast tool-call turnaround. Using the same model for both forces a compromise. The separate model config lets users optimize each path independently.

GitHub Actions Context Has Information Asymmetry

Section titled “GitHub Actions Context Has Information Asymmetry”

OpenCode’s GitHub Actions integration injects rich PR context (all comments, all reviews, all changed files). But the model still needs to run git diff to see the actual code changes — the GitHub API provides file paths and line counts but not the full diff content. This creates a two-phase flow: read metadata from the injected context, then use tools to fetch the actual diff. If the model tries to review based solely on the injected metadata (which includes diffHunk only for the specific review comment, not the full PR diff), its findings will be shallow.


OpenOxide treats review as a first-class mode, not a prompt variation. The review pipeline is separate from the interactive agent loop.

openoxide review --uncommitted # Working tree changes
openoxide review --base main # Against base branch
openoxide review --commit abc123 # Specific commit
openoxide review "custom instructions" # Free-form
openoxide review --base main --model gpt-5.2-codex # Override review model

Implemented via clap subcommand, matching Codex’s design. The --model flag overrides the default model for the review task only.

Instead of shelling out to git merge-base, use the git2 crate:

// crate: openoxide-git
pub fn merge_base_with_head(
repo: &git2::Repository,
branch: &str,
) -> Result<Option<git2::Oid>> {
let head = repo.head()?.peel_to_commit()?.id();
let branch_oid = repo
.find_branch(branch, git2::BranchType::Local)
.or_else(|_| repo.find_branch(
&format!("origin/{branch}"),
git2::BranchType::Remote,
))?
.get()
.peel_to_commit()?
.id();
Ok(Some(repo.merge_base(head, branch_oid)?))
}

No fallback prompt needed — if merge-base cannot be computed, the review fails immediately with a clear error. The model should not be asked to do git plumbing.

Adopt Codex’s review rubric wholesale (it is well-calibrated) with one addition: severity-dependent detail. P0/P1 findings require a reproducing scenario. P2/P3 findings are brief.

Reuse the ReviewOutputEvent structure from Codex but with validation:

// crate: openoxide-review
pub struct ReviewOutput {
pub findings: Vec<Finding>,
pub overall_correctness: Correctness,
pub overall_explanation: String,
pub overall_confidence: f32,
}
pub enum Correctness {
Correct,
Incorrect,
}
pub struct Finding {
pub title: String, // max 80 chars, priority-tagged
pub body: String, // Markdown
pub confidence: f32, // 0.0-1.0
pub priority: Priority,
pub location: CodeLocation,
}
pub enum Priority { P0, P1, P2, P3 }
pub struct CodeLocation {
pub file: PathBuf,
pub start_line: u32,
pub end_line: u32,
}

Parse the model’s JSON output with serde_json::from_str, with a preprocessing step that strips markdown fences if present (since models often add them despite instructions).

Review tasks run in a read-only sandbox by default. The model can use git diff, git log, git show, file reads, and grep — but not file writes or shell commands that modify the working tree. This eliminates the trust concern of a review accidentally modifying code.

  • openoxide-review: ReviewOutput, Finding, Priority, Correctness types. JSON parsing with fence-stripping preprocessor. Review system prompt.
  • openoxide-git: merge_base_with_head() via git2. Diff generation for review context.
  • openoxide-core: Review task spawning, model selection, sandbox policy override.
  1. Hard-fail on merge-base computation: No fallback prompt. If git2 cannot compute the merge-base, the review does not start. This prevents the model from producing a review against the wrong baseline.
  2. Read-only sandbox by default: Reviews should observe, not modify. If the user wants auto-fix behavior, they can pass --sandbox=full-access explicitly.
  3. Fence-stripping JSON parser: Accept the reality that models add markdown fences around JSON. Strip them before parsing instead of relying on prompt instructions alone.
  4. Separate review model config: Following Codex, support review_model in config. Default to the session model if unset.
  5. No GitHub Actions integration in v1: Start with the CLI review command. GitHub/GitLab integration is a separate concern that depends on the webhook server architecture (not yet designed). The pr checkout convenience command (matching OpenCode’s) can be added early since it is just shell orchestration.