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 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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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
@@ -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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user