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:
+8
-107
@@ -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<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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod review;
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+50
-1
@@ -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::<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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user