clean project structure
ci/woodpecker/push/tests Pipeline was canceled

This commit is contained in:
qpismont committed 2026-10-13 19:57:24 +00:00
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))
}