211 lines
5.8 KiB
Rust
211 lines
5.8 KiB
Rust
//! 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<f64>,
|
|
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<Tool>,
|
|
payload: &ReviewPayload,
|
|
) -> anyhow::Result<ReviewOutcome> {
|
|
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<Tool>,
|
|
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<Tool>,
|
|
payload: &ReviewPayload,
|
|
bot_request: &str,
|
|
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
|
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))
|
|
}
|