use futures_util::stream::TryStreamExt; use tokio::io::AsyncReadExt; use tokio_util::io::StreamReader; use tracing::instrument; use crate::{ bot::ReviewResult, consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, gitea::{GiteaAPI, ReviewPayload}, open_router::OpenRouterClient, }; #[instrument(skip(gitea_api, open_router_client, http_client, review_payload))] pub async fn exec_review( gitea_api: &GiteaAPI, open_router_client: &OpenRouterClient, http_client: &reqwest::Client, model: &str, review_payload: ReviewPayload, ) -> anyhow::Result<()> { tracing::info!( repo = %review_payload.repository.full_name, pr = review_payload.pull_request.number, action = %review_payload.action, "Starting review" ); let new_comment = gitea_api .comment( &BOT_PROCESS_MSG.replace("{model}", model), &review_payload.repository.full_name, review_payload.pull_request.number, ) .await?; let bot_result: Result = async { let git_diff = download_git_diff(http_client, &review_payload.pull_request.diff_url).await?; let diff_for_llm = format_diff_for_review(&git_diff); let bot_request = REVIEW_PROMPT .replace("{subject}", &review_payload.pull_request.title) .replace("{comment}", &review_payload.comment.body) .replace("{diff}", &diff_for_llm); let chat_result = open_router_client.chat(&bot_request).await?; let mut review_result = serde_json::from_str::(&chat_result.message)?; review_result.cost = chat_result.cost; let final_review_markdown = review_result_to_markdown(&review_result); gitea_api .post_pull_request_review( &review_result, &final_review_markdown, &review_payload.repository.full_name, review_payload.pull_request.number, ) .await?; Ok(review_result) } .await; match bot_result { Ok(_) => { gitea_api .delete_comment(&review_payload.repository.full_name, new_comment.id) .await } Err(e) => { gitea_api .edit_comment( &format!("Error while reviewing: {}", e), &review_payload.repository.full_name, new_comment.id, ) .await } } } fn review_result_to_markdown(review_result: &ReviewResult) -> String { if review_result.reviews.is_empty() { return String::from("No issues found. ✅"); } let mut md = String::from("## Review Feedback\n\n"); md.push_str(&format!( "### {} issues found.\n\n", review_result.reviews.len() )); if !review_result.comment.is_empty() { md.push_str("\n---\n\n"); md.push_str("### Summary\n\n"); md.push_str(&review_result.comment); md.push('\n'); } if let Some(cost) = review_result.cost { md.push_str("\n---\n\n"); md.push_str(&format!("### Cost: ${}", cost)); md.push('\n'); } md } async fn download_git_diff(http_client: &reqwest::Client, url: &str) -> anyhow::Result { let response = http_client.get(url).send().await?; let stream = response.bytes_stream().map_err(std::io::Error::other); let mut buf = Vec::with_capacity(MAX_DIFF_SIZE); StreamReader::new(stream) .take((MAX_DIFF_SIZE + 1) as u64) .read_to_end(&mut buf) .await?; if buf.len() > MAX_DIFF_SIZE { anyhow::bail!("Git diff exceeds the maximum allowed size of 1 Mo"); } Ok(String::from_utf8_lossy(&buf).into_owned()) } fn format_diff_for_review(git_diff: &str) -> String { let mut output = String::new(); let mut current_file: Option<&str> = None; let mut new_line: u64 = 0; for line in git_diff.lines() { if let Some(rest) = line.strip_prefix("diff --git a/") { if let Some(end) = rest.find(' ') { current_file = Some(&rest[..end]); } new_line = 0; continue; } if line.starts_with("---") || line.starts_with("+++") { continue; } if line.starts_with("@@") && line.contains('+') { if let Some(start) = parse_hunk_new_start(line) { new_line = start; } continue; } let Some(filename) = current_file else { continue; }; if line.starts_with(' ') { new_line += 1; continue; } if let Some(code) = line.strip_prefix('+') { use std::fmt::Write; let _ = writeln!(output, "{filename}:{new_line}:{code}"); new_line += 1; } } output } fn parse_hunk_new_start(hunk_header: &str) -> Option { let plus_part = hunk_header.split('+').nth(1)?; let num_str = plus_part.split(|c: char| !c.is_ascii_digit()).next()?; num_str.parse::().ok() } #[cfg(test)] #[test] fn test_format_diff_for_review() { let diff = concat!( "diff --git a/src/foo.rs b/src/foo.rs\n", "--- a/src/foo.rs\n", "+++ b/src/foo.rs\n", "@@ -1,3 +1,6 @@\n", " fn main() {\n", "+ let x = 1;\n", " println!(\"hello\");\n", "+ let y = 2;\n", "+ let z = 3;\n", " }\n", "diff --git a/src/bar.rs b/src/bar.rs\n", "--- a/src/bar.rs\n", "+++ b/src/bar.rs\n", "@@ -10,4 +10,6 @@\n", " old context\n", "+ let a = 10;\n", " more context\n", "+ let b = 20;\n", ); let result = format_diff_for_review(diff); let expected = concat!( "src/foo.rs:2: let x = 1;\n", "src/foo.rs:4: let y = 2;\n", "src/foo.rs:5: let z = 3;\n", "src/bar.rs:11: let a = 10;\n", "src/bar.rs:13: let b = 20;\n", ); assert_eq!(result, expected); }