Compare commits

..

10 Commits

Author SHA1 Message Date
qpismont 3984a7d3ba Merge pull request 'started gitea api impl' (#2) from gitea_api into main
Reviewed-on: #2
2026-06-07 10:23:31 +02:00
qpismont aa0dbdcc7a 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.
2026-06-06 17:27:35 +00:00
qpismont ced1fca563 Add gitea download git diff limit 2026-06-05 19:34:29 +00:00
qpismont 6aa653e846 Add http status check for gitea api 2026-06-05 18:52:59 +00:00
qpismont 3501e4ae9d Use reqwest client with timeout in gitea.rs and bot.rs 2026-06-05 18:48:02 +00:00
qpismont 01e13f0081 Add default authorization header for gitea api (remove query string)
Add review cost
2026-06-05 18:39:38 +00:00
qpismont cd5c5b9478 Use reqwest 0.12 with rustls-tls and add timeouts
Also improve review prompt with line calculation instructions, switch
feedback to French, and enable reasoning for OpenRouter.
2026-06-03 20:51:21 +00:00
qpismont de81232201 Integrate OpenRouter for AI-powered code review
Add openrouter-rs dependency, review prompt, and markdown formatting.
Update comment API to accept dynamic body. Adjust devcontainer for
podman compatibility.
2026-06-03 19:38:00 +00:00
qpismont 4966d08d18 first comment ! :D 2026-06-02 20:30:02 +00:00
qpismont 10ebee389e started gitea api impl 2026-06-02 19:52:50 +00:00
13 changed files with 843 additions and 418 deletions
+1 -11
View File
@@ -12,18 +12,8 @@
"containerEnv": { "containerEnv": {
"SHELL": "/bin/bash" "SHELL": "/bin/bash"
}, },
"customizations": {
"vscode": {
"extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml", "fill-labs.dependi"],
"settings": {
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer",
"editor.formatOnSave": true
}
}
}
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind",
"workspaceFolder": "/workspaces/herald", "workspaceFolder": "/workspaces/herald",
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
"appPort": [3000] "appPort": [3000]
} }
Generated
+258 -379
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -4,10 +4,14 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
reqwest = { version = "0.13", features = ["json"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1.52", features = ["full"] } tokio = { version = "1.52", features = ["full"] }
tokio-stream = "0.1"
tokio-util = "0.7"
futures-util = "0.3"
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
openrouter-rs = "0.10"
dotenvy = "0.15" dotenvy = "0.15"
axum = "0.8" axum = "0.8"
anyhow = "1.0" anyhow = "1.0"
@@ -16,4 +20,4 @@ hmac = "0.13"
sha2 = "0.11" sha2 = "0.11"
hex = "0.4" hex = "0.4"
subtle = "2.6" subtle = "2.6"
bytes = "1.11" bytes = "1.11"
+14 -4
View File
@@ -1,9 +1,10 @@
use axum::body::{Bytes, to_bytes}; use axum::body::{Bytes, to_bytes};
use axum::extract::{FromRef, FromRequest}; use axum::extract::{FromRef, FromRequest, State};
use axum::response::{IntoResponse, Response}; use axum::response::IntoResponse;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use hmac::{Hmac, KeyInit, Mac}; use hmac::{Hmac, KeyInit, Mac};
use reqwest::StatusCode;
use serde_json::Value; use serde_json::Value;
use sha2::Sha256; use sha2::Sha256;
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
@@ -31,8 +32,17 @@ async fn root() -> &'static str {
"Hi, i'm Herald :)" "Hi, i'm Herald :)"
} }
async fn webhook(WebhookExtract(wb): WebhookExtract) -> Result<Response, AppError> { async fn webhook(
Ok("lol".into_response()) State(app_state): State<AppState>,
WebhookExtract(wb): WebhookExtract,
) -> Result<impl IntoResponse, AppError> {
app_state
.bot_tx
.send(wb)
.await
.map_err(anyhow::Error::from)?;
Ok((StatusCode::CREATED, "Task started"))
} }
pub struct WebhookExtract(pub WebhookType); pub struct WebhookExtract(pub WebhookType);
+108 -4
View File
@@ -1,13 +1,117 @@
use crate::{env::EnvConfig, gitea::WebhookType}; use serde::Deserialize;
use std::time::Duration;
use crate::{
env::EnvConfig,
gitea::{GiteaAPI, WebhookType},
open_router::OpenRouterClient,
};
#[derive(Deserialize, Debug)]
pub struct ReviewResult {
pub reviews: Vec<ReviewItem>,
pub comment: String,
pub cost: Option<f64>,
}
#[derive(Deserialize, Debug)]
pub struct ReviewItem {
pub filename: String,
pub line: Option<u64>,
pub code: String,
pub message: String,
}
/// Map a filename to a markdown language identifier for syntax highlighting.
fn lang_from_filename(filename: &str) -> &str {
match std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
{
"rs" => "rust",
"py" => "python",
"js" | "mjs" => "javascript",
"ts" => "typescript",
"jsx" => "jsx",
"tsx" => "tsx",
"go" => "go",
"java" => "java",
"kt" | "kts" => "kotlin",
"scala" => "scala",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
"rb" => "ruby",
"php" => "php",
"swift" => "swift",
"sh" | "bash" | "zsh" => "bash",
"sql" => "sql",
"html" | "htm" => "html",
"css" => "css",
"scss" | "sass" => "scss",
"json" => "json",
"yaml" | "yml" => "yaml",
"xml" => "xml",
"toml" => "toml",
"md" | "mdx" => "markdown",
"dockerfile" | "Dockerfile" => "dockerfile",
"Makefile" => "makefile",
_ => "",
}
}
pub struct Bot { pub struct Bot {
config: EnvConfig, config: EnvConfig,
gitea_api: GiteaAPI,
open_router_client: OpenRouterClient,
http_client: reqwest::Client,
} }
impl Bot { impl Bot {
pub fn new(config: EnvConfig) -> Self { pub fn new(config: EnvConfig) -> anyhow::Result<Self> {
Self { config } let gitea_timeout = config.gitea_timeout;
let open_router_timeout = config.open_router_timeout;
Ok(Self {
gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token, gitea_timeout)?,
open_router_client: OpenRouterClient::new(
&config.open_router_api_key,
&config.open_router_model,
open_router_timeout,
)?,
config,
http_client: reqwest::Client::builder()
.timeout(Duration::from_secs(gitea_timeout))
.build()?,
})
} }
pub async fn exec(&self, webhook: WebhookType) {} pub async fn start(
&self,
mut rx: tokio::sync::mpsc::Receiver<WebhookType>,
) -> anyhow::Result<()> {
while let Some(wb) = rx.recv().await {
self.exec(wb).await;
}
Ok(())
}
pub async fn exec(&self, webhook: WebhookType) {
let exec_result = match webhook {
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;
match exec_result {
Ok(_) => println!("Task completed"),
Err(err) => println!("{}", err),
}
}
} }
+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);
}
+41
View File
@@ -1,3 +1,44 @@
pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature"; pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature";
pub const GITEA_EVENT_TYPE_HEADER_NAME: &str = "x-gitea-event-type"; pub const GITEA_EVENT_TYPE_HEADER_NAME: &str = "x-gitea-event-type";
pub const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 MiB pub const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 MiB
pub const MAX_DIFF_SIZE: usize = 1024 * 1024; // 1 MiB
pub const BOT_PROCESS_MSG: &str = "
Review in progress with the model \"{model}\"...
";
pub const REVIEW_PROMPT: &str = "
You are a senior software engineer reviewing code changes.
Check good practices and code quality.
This is the pull request subject: \"{subject}\"
This is the user comment: \"{comment}\"
The code changes (only added lines, with line numbers):
{diff}
Please review the code changes and provide feedback.
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.
(filename field must contain the full path with extension) and comment must contain a final summary:
{
\"reviews\": [
{
\"filename\": \"\",
\"line\": ,
\"code\": \"\",
\"message\": \"\"
}
],
\"comment\": \"\"
}
";
+15
View File
@@ -6,7 +6,12 @@ pub struct EnvConfig {
pub http_port: u16, pub http_port: u16,
pub webhook_secret: String, pub webhook_secret: String,
pub open_router_api_key: String, pub open_router_api_key: String,
pub open_router_model: String,
pub open_router_timeout: u64,
pub bot_name: String, pub bot_name: String,
pub gitea_url: String,
pub gitea_token: String,
pub gitea_timeout: u64,
} }
pub fn load_config() -> anyhow::Result<EnvConfig> { pub fn load_config() -> anyhow::Result<EnvConfig> {
@@ -16,12 +21,22 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
let bot_name = try_get_env("BOT_NAME")?; let bot_name = try_get_env("BOT_NAME")?;
let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?;
let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?;
let open_router_model = try_get_env("OPEN_ROUTER_MODEL")?;
let open_router_timeout = try_get_env("OPEN_ROUTER_TIMEOUT")?.parse()?;
let gitea_url = try_get_env("GITEA_URL")?;
let gitea_token = try_get_env("GITEA_TOKEN")?;
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
Ok(EnvConfig { Ok(EnvConfig {
http_port, http_port,
webhook_secret, webhook_secret,
bot_name, bot_name,
open_router_api_key, open_router_api_key,
open_router_model,
open_router_timeout,
gitea_url,
gitea_token,
gitea_timeout,
}) })
} }
+139 -3
View File
@@ -1,7 +1,135 @@
use serde::Deserialize; use std::time::Duration;
use serde_json::Value;
use crate::errors::AppError; use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
bot::{ReviewItem, ReviewResult},
errors::AppError,
};
pub struct GiteaAPI {
base_url: String,
client: reqwest::Client,
}
impl GiteaAPI {
pub fn new(base_url: &str, token: &str, timeout: u64) -> anyhow::Result<Self> {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::HeaderName::from_static("authorization"),
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?,
);
Ok(Self {
base_url: String::from(base_url),
client: reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.default_headers(default_headers)
.build()?,
})
}
pub async fn comment(
&self,
body: &str,
full_name: &str,
index: u64,
) -> anyhow::Result<Comment> {
let url = format!(
"{}/api/v1/repos/{}/issues/{}/comments",
self.base_url, full_name, index
);
let res = self
.client
.post(url)
.json(&json!({
"body": body
}))
.send()
.await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!("Failed to comment: {}", res.status()));
}
res.json::<Comment>().await.map_err(anyhow::Error::from)
}
pub async fn edit_comment(
&self,
body: &str,
full_name: &str,
comment_id: u64,
) -> anyhow::Result<()> {
let url = format!(
"{}/api/v1/repos/{}/issues/comments/{}",
self.base_url, full_name, comment_id
);
let res = self
.client
.patch(url)
.json(&json!({
"body": body
}))
.send()
.await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!("Failed to comment: {}", res.status()));
}
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)]
pub enum WebhookType { pub enum WebhookType {
@@ -12,6 +140,7 @@ pub enum WebhookType {
pub struct ReviewPayload { pub struct ReviewPayload {
pub action: String, pub action: String,
pub pull_request: PullRequest, pub pull_request: PullRequest,
pub repository: Repository,
pub comment: Comment, pub comment: Comment,
} }
@@ -19,6 +148,8 @@ pub struct ReviewPayload {
pub struct PullRequest { pub struct PullRequest {
pub id: u64, pub id: u64,
pub diff_url: String, pub diff_url: String,
pub number: u64,
pub title: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -33,6 +164,11 @@ pub struct User {
pub id: u64, pub id: u64,
} }
#[derive(Deserialize, Debug)]
pub struct Repository {
pub full_name: String,
}
impl WebhookType { impl WebhookType {
pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> { pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> {
let wb = match event { let wb = match event {
+10 -10
View File
@@ -1,25 +1,25 @@
use std::sync::Arc; use crate::{bot::Bot, gitea::WebhookType, state::AppState};
use tokio::sync::Mutex;
use crate::{bot::Bot, 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;
mod gitea; mod gitea;
mod open_router;
mod state; mod state;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let config = env::load_config()?; let config = env::load_config()?;
let bot = Bot::new(config.clone())?;
let app_state = AppState { let (tx, rx) = tokio::sync::mpsc::channel::<WebhookType>(1);
bot: Arc::new(Mutex::new(Bot::new(config.clone()))),
config: config,
};
api::start(app_state).await let app_state = AppState { bot_tx: tx, config };
tokio::try_join!(bot.start(rx), api::start(app_state))?;
Ok(())
} }
+47
View File
@@ -0,0 +1,47 @@
use std::time::Duration;
use openrouter_rs::{Message, api::chat::ChatCompletionRequest};
pub struct ChatResult {
pub message: String,
pub cost: Option<f64>,
}
pub struct OpenRouterClient {
client: openrouter_rs::OpenRouterClient,
model: String,
}
impl OpenRouterClient {
pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result<Self> {
Ok(Self {
client: openrouter_rs::OpenRouterClient::builder()
.api_key(token)
.http_client(
reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.build()?,
)
.build()?,
model: String::from(model),
})
}
pub async fn chat(&self, msg: &str) -> anyhow::Result<ChatResult> {
let request = ChatCompletionRequest::builder()
.model(&self.model)
.enable_reasoning()
.messages(vec![Message::new(openrouter_rs::types::Role::User, msg)])
.build()?;
let response = self.client.chat().create(&request).await?;
Ok(ChatResult {
message: response.choices[0]
.content()
.map(|msg| String::from(msg))
.ok_or(anyhow::anyhow!("No content"))?,
cost: response.usage.and_then(|u| u.cost),
})
}
}
+2 -5
View File
@@ -1,10 +1,7 @@
use std::sync::Arc; use crate::{env::EnvConfig, gitea::WebhookType};
use tokio::sync::Mutex;
use crate::{bot::Bot, env::EnvConfig};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub bot: Arc<Mutex<Bot>>, pub bot_tx: tokio::sync::mpsc::Sender<WebhookType>,
pub config: EnvConfig, pub config: EnvConfig,
} }