From aa0dbdcc7a59ac91f3ddef4931e45468dcf5761b Mon Sep 17 00:00:00 2001 From: qpismont Date: Sat, 6 Jun 2026 17:27:35 +0000 Subject: [PATCH] Extract review logic into bot_actions module Move `exec_review`, `download_git_diff`, and review formatting to a new `bot_actions::review` module. Update the review flow to post inline review comments via the Gitea API and simplify the comment markdown to a summary. Add diff formatting that preprocesses added lines with line numbers for the LLM prompt. --- src/bot.rs | 115 ++-------------------- src/bot_actions/mod.rs | 1 + src/bot_actions/review.rs | 201 ++++++++++++++++++++++++++++++++++++++ src/consts.rs | 13 +-- src/gitea.rs | 51 +++++++++- src/main.rs | 1 + 6 files changed, 265 insertions(+), 117 deletions(-) create mode 100644 src/bot_actions/mod.rs create mode 100644 src/bot_actions/review.rs diff --git a/src/bot.rs b/src/bot.rs index d03d2fd..bd9b62c 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,14 +1,9 @@ -use futures_util::stream::TryStreamExt; use serde::Deserialize; use std::time::Duration; -use tokio::io::AsyncReadExt; -use tokio_util::io::StreamReader; use crate::{ - consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, env::EnvConfig, - errors::AppError, - gitea::{GiteaAPI, ReviewPayload, WebhookType}, + gitea::{GiteaAPI, WebhookType}, open_router::OpenRouterClient, }; @@ -104,7 +99,13 @@ impl Bot { pub async fn exec(&self, webhook: WebhookType) { let exec_result = match webhook { - WebhookType::Review(review_payload) => self.exec_review(review_payload), + WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review( + &self.gitea_api, + &self.open_router_client, + &self.http_client, + &self.config.open_router_model, + review_payload, + ), } .await; @@ -113,104 +114,4 @@ impl Bot { Err(err) => println!("{}", err), } } - - pub async fn exec_review(&self, review_payload: ReviewPayload) -> Result<(), AppError> { - let new_comment = self - .gitea_api - .comment( - &BOT_PROCESS_MSG.replace("{model}", &self.config.open_router_model), - &review_payload.repository.full_name, - review_payload.pull_request.number, - ) - .await?; - - let bot_result: Result = async { - let git_diff = self - .download_git_diff(&review_payload.pull_request.diff_url) - .await?; - - let bot_request = REVIEW_PROMPT - .replace("{subject}", &review_payload.pull_request.title) - .replace("{comment}", &review_payload.comment.body) - .replace("{diff}", &git_diff); - - let chat_result = self.open_router_client.chat(&bot_request).await?; - let mut review_result = serde_json::from_str::(&chat_result.message)?; - - review_result.cost = chat_result.cost; - - Ok(review_result) - } - .await; - - let edit_msg = match bot_result { - Ok(bot_result) => self.review_result_to_markdown(&bot_result), - Err(e) => format!("Error while reviewing: {}", e), - }; - - self.gitea_api - .edit_comment( - &edit_msg, - &review_payload.repository.full_name, - new_comment.id, - ) - .await?; - - Ok(()) - } - - fn review_result_to_markdown(&self, 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"); - for (i, item) in review_result.reviews.iter().enumerate() { - if i > 0 { - md.push_str("\n---\n\n"); - } - md.push_str(&format!("### `{}`\n\n", item.filename)); - if let Some(line) = item.line { - md.push_str(&format!("> **Line {}**\n\n", line)); - } - if !item.code.is_empty() { - let lang = lang_from_filename(&item.filename); - md.push_str(&format!("```{}\n{}\n```\n\n", lang, item.code)); - } - md.push_str(&item.message); - md.push('\n'); - } - - 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(&self, url: &str) -> anyhow::Result { - let response = self.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()) - } } diff --git a/src/bot_actions/mod.rs b/src/bot_actions/mod.rs new file mode 100644 index 0000000..0755bd4 --- /dev/null +++ b/src/bot_actions/mod.rs @@ -0,0 +1 @@ +pub mod review; diff --git a/src/bot_actions/review.rs b/src/bot_actions/review.rs new file mode 100644 index 0000000..8ffc3b1 --- /dev/null +++ b/src/bot_actions/review.rs @@ -0,0 +1,201 @@ +use futures_util::stream::TryStreamExt; +use tokio::io::AsyncReadExt; +use tokio_util::io::StreamReader; + +use crate::{ + bot::ReviewResult, + consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, + errors::AppError, + gitea::{GiteaAPI, ReviewPayload}, + open_router::OpenRouterClient, +}; + +pub async fn exec_review( + gitea_api: &GiteaAPI, + open_router_client: &OpenRouterClient, + http_client: &reqwest::Client, + model: &str, + review_payload: ReviewPayload, +) -> Result<(), AppError> { + 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; + + gitea_api + .post_pull_request_review( + &review_result, + &review_payload.repository.full_name, + review_payload.pull_request.number, + ) + .await?; + + Ok(review_result) + } + .await; + + let edit_msg = match bot_result { + Ok(bot_result) => review_result_to_markdown(&bot_result), + Err(e) => format!("Error while reviewing: {}", e), + }; + + gitea_api + .edit_comment( + &edit_msg, + &review_payload.repository.full_name, + new_comment.id, + ) + .await?; + + Ok(()) +} + +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); +} diff --git a/src/consts.rs b/src/consts.rs index f3ae2dd..9a38974 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -17,19 +17,14 @@ pub const REVIEW_PROMPT: &str = " This is the user comment: \"{comment}\" - This is the git diff of the code changes: + The code changes (only added lines, with line numbers): - \"{diff}\" + {diff} Please review the code changes and provide feedback. - IMPORTANT — How to compute the line number: - The diff is in unified format. Each hunk starts with a header like: - `@@ -old_start,old_count +new_start,new_count @@` - The `line` field must be the line number in the **new** version of the file. - To find it, start at `new_start`, then count every context line (no prefix) - and every added line (prefixed with `+`) — skip removed lines (prefixed with `-`). - The line number increments by 1 for each context or added line. + IMPORTANT: the `line` field must be the line number shown before each line. + The provided code has the format: `filename:line:code` Return your feedback, in french, with only this json format, reviews must contain each review All fields are mandatory. diff --git a/src/gitea.rs b/src/gitea.rs index 1a3581e..5a725ea 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -3,7 +3,10 @@ use std::time::Duration; use serde::Deserialize; use serde_json::{Value, json}; -use crate::errors::AppError; +use crate::{ + bot::{ReviewItem, ReviewResult}, + errors::AppError, +}; pub struct GiteaAPI { base_url: String, @@ -80,6 +83,52 @@ impl GiteaAPI { Ok(()) } + + pub async fn post_pull_request_review( + &self, + review_result: &ReviewResult, + full_name: &str, + index: u64, + ) -> anyhow::Result<()> { + let url = format!( + "{}/api/v1/repos/{}/pulls/{}/reviews", + self.base_url, full_name, index + ); + + let comments = &&review_result + .reviews + .iter() + .filter(|r| r.line.is_some()) + .map(|r| { + let path = r.filename.clone(); + let line = r.line.unwrap_or(0); + let body = r.message.clone(); + + json!({ + "path": path, + "new_position": line, + "body": body + }) + }) + .collect::>(); + + let res = self + .client + .post(url) + .json(&json!({ + "event": "COMMENT", + "body": review_result.comment, + "comments": comments + })) + .send() + .await?; + + if !res.status().is_success() { + return Err(anyhow::anyhow!("Failed to post review: {}", res.status())); + } + + Ok(()) + } } #[derive(Debug)] diff --git a/src/main.rs b/src/main.rs index ad78bc0..f1a8ad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use crate::{bot::Bot, gitea::WebhookType, state::AppState}; mod api; mod bot; +mod bot_actions; mod consts; mod env; mod errors;