//! The pull request review action. //! //! [`exec_review`] drives one review end to end. The work is split by concern: //! - [`diff`] parses the pull request diff into the lines it changes, //! - [`prompt`] renders those changes for the model, //! - [`anchor`] checks that the model's reviews land on changed lines, //! - [`render`] builds the markdown summary posted on the pull request. mod anchor; mod diff; mod prompt; mod render; #[cfg(test)] mod fixtures; use tracing::{info, instrument, warn}; use crate::{ consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, domain::review::ReviewResult, event_bus::Reporter, gitea::{GiteaAPI, ReviewPayload}, metrics, open_router::{OpenRouterClient, Tool}, sandbox::{Sandbox, SandboxConfig, agent}, }; use self::{ anchor::resolve_review_sides, diff::{drop_generated_files, parse_changed_lines, resolve_filenames}, prompt::format_changes, render::review_result_to_markdown, }; /// The services a review needs, borrowed from the bot for the duration of one job. pub struct ReviewContext<'a> { pub gitea: &'a GiteaAPI, pub open_router: &'a OpenRouterClient, pub sandbox: &'a SandboxConfig, pub reporter: &'a Reporter, } pub struct ReviewOutcome { pub markdown: String, pub cost: Option, pub comments: u64, } impl ReviewOutcome { pub fn empty() -> Self { Self { markdown: String::new(), cost: None, comments: 0, } } } #[instrument( skip_all, fields(repo = %payload.repository.full_name, pr = payload.pull_request.number) )] pub async fn exec_review( ctx: &ReviewContext<'_>, tools: Vec, payload: &ReviewPayload, ) -> anyhow::Result { info!(action = %payload.action, "Starting review"); let full_name = &payload.repository.full_name; let index = payload.pull_request.number; let progress_comment = ctx .gitea .comment( &BOT_PROCESS_MSG.replace("{model}", ctx.open_router.model()), full_name, index, ) .await?; let review = review_pull_request(ctx, tools, payload).await; // The progress comment is housekeeping: failing to update it must neither turn a // posted review into a failure nor hide the error that made the review fail. match review { Ok((review_result, markdown)) => { if let Err(err) = ctx .gitea .delete_comment(full_name, progress_comment.id) .await { warn!(%err, "Failed to delete the progress comment"); } Ok(ReviewOutcome { markdown, cost: review_result.cost, comments: review_result.reviews.len() as u64, }) } Err(err) => { if let Err(edit_err) = ctx .gitea .edit_comment( &format!("Error while reviewing: {err}"), full_name, progress_comment.id, ) .await { warn!(err = %edit_err, "Failed to report the error on the progress comment"); } Err(err) } } } /// Runs the review and posts it, returning the result and the markdown that was /// posted. async fn review_pull_request( ctx: &ReviewContext<'_>, tools: Vec, payload: &ReviewPayload, ) -> anyhow::Result<(ReviewResult, String)> { let full_name = &payload.repository.full_name; let index = payload.pull_request.number; let git_diff = ctx.gitea.pull_request_diff(full_name, index).await?; // The file list only refines the paths and describes the changes: a failure is // not fatal, the diff is the source of truth for the lines. let files = match ctx.gitea.pull_request_files(full_name, index).await { Ok(files) => files, Err(err) => { warn!(%err, "Failed to list the pull request files"); Vec::new() } }; let mut changed_lines = parse_changed_lines(&git_diff); drop_generated_files(&mut changed_lines); resolve_filenames(&mut changed_lines, &files); let changes = format_changes(&files, &changed_lines); let bot_request = REVIEW_PROMPT .replace("{subject}", &payload.pull_request.title) .replace("{comment}", &payload.comment.body) .replace("{changes}", &changes); let (mut review_result, cost) = run_sandboxed_review(ctx, tools, payload, &bot_request).await?; resolve_review_sides(&mut review_result, &changed_lines); review_result.cost = cost; if let Some(cost) = review_result.cost { metrics::openrouter_cost_usd(cost); } let markdown = review_result_to_markdown(&review_result); ctx.gitea .post_pull_request_review(&review_result, &markdown, full_name, index) .await?; Ok((review_result, markdown)) } async fn run_sandboxed_review( ctx: &ReviewContext<'_>, tools: Vec, payload: &ReviewPayload, bot_request: &str, ) -> anyhow::Result<(ReviewResult, Option)> { let sandbox = Sandbox::create( &ctx.sandbox.runtime, &payload.repository.clone_url, ctx.gitea.token(), payload.pull_request.number, ctx.reporter, ) .await?; let result = agent::run( ctx.open_router, &sandbox, tools, SANDBOX_SYSTEM_PROMPT, bot_request, ctx.sandbox.max_iterations, ctx.reporter, ) .await; if let Err(err) = sandbox.cleanup().await { warn!(%err, "Failed to clean up sandbox container"); } let result = result?; info!( iterations = result.iterations, cost = ?result.cost, "Sandboxed review finished" ); Ok((result.answer, result.cost)) }