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.
This commit is contained in:
2026-06-06 17:27:35 +00:00
parent ced1fca563
commit aa0dbdcc7a
6 changed files with 265 additions and 117 deletions
+8 -107
View File
@@ -1,14 +1,9 @@
use futures_util::stream::TryStreamExt;
use serde::Deserialize; use serde::Deserialize;
use std::time::Duration; use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio_util::io::StreamReader;
use crate::{ use crate::{
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT},
env::EnvConfig, env::EnvConfig,
errors::AppError, gitea::{GiteaAPI, WebhookType},
gitea::{GiteaAPI, ReviewPayload, WebhookType},
open_router::OpenRouterClient, open_router::OpenRouterClient,
}; };
@@ -104,7 +99,13 @@ impl Bot {
pub async fn exec(&self, webhook: WebhookType) { pub async fn exec(&self, webhook: WebhookType) {
let exec_result = match webhook { 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; .await;
@@ -113,104 +114,4 @@ impl Bot {
Err(err) => println!("{}", err), 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<ReviewResult, anyhow::Error> = 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::<ReviewResult>(&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<String> {
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())
}
} }
+1
View File
@@ -0,0 +1 @@
pub mod review;
+201
View File
@@ -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<ReviewResult, anyhow::Error> = 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::<ReviewResult>(&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<String> {
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<u64> {
let plus_part = hunk_header.split('+').nth(1)?;
let num_str = plus_part.split(|c: char| !c.is_ascii_digit()).next()?;
num_str.parse::<u64>().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);
}
+4 -9
View File
@@ -17,19 +17,14 @@ pub const REVIEW_PROMPT: &str = "
This is the user comment: \"{comment}\" 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. Please review the code changes and provide feedback.
IMPORTANT — How to compute the line number: IMPORTANT: the `line` field must be the line number shown before each line.
The diff is in unified format. Each hunk starts with a header like: The provided code has the format: `filename:line:code`
`@@ -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.
Return your feedback, in french, with only this json format, reviews must contain each review Return your feedback, in french, with only this json format, reviews must contain each review
All fields are mandatory. All fields are mandatory.
+50 -1
View File
@@ -3,7 +3,10 @@ use std::time::Duration;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::errors::AppError; use crate::{
bot::{ReviewItem, ReviewResult},
errors::AppError,
};
pub struct GiteaAPI { pub struct GiteaAPI {
base_url: String, base_url: String,
@@ -80,6 +83,52 @@ impl GiteaAPI {
Ok(()) 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::<Vec<_>>();
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)] #[derive(Debug)]
+1
View File
@@ -2,6 +2,7 @@ use crate::{bot::Bot, gitea::WebhookType, state::AppState};
mod api; mod api;
mod bot; mod bot;
mod bot_actions;
mod consts; mod consts;
mod env; mod env;
mod errors; mod errors;