1 parent
7493de6952
commit
3078ba5705
45 files changed
+2139
-1880
No files matched your search
@@ -0,0 +1,210 @@
|
||||
//! 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))
|
||||
}
|
||||
Reference in new issue
Block a user