started gitea api impl #2

Merged
qpismont merged 9 commits from gitea_api into main 2026-06-07 10:23:31 +02:00
6 changed files with 265 additions and 117 deletions
Showing only changes of commit aa0dbdcc7a - Show all commits
+8 -107
View File
@@ -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())
}
}
+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 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
View File
@@ -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)]
+1
View File
@@ -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;