From 10ebee389e1625febb340de3c7d4b78dd90bf201 Mon Sep 17 00:00:00 2001 From: qpismont Date: Tue, 2 Jun 2026 19:52:50 +0000 Subject: [PATCH 1/9] started gitea api impl --- src/api.rs | 18 ++++++++++++++---- src/bot.rs | 39 ++++++++++++++++++++++++++++++++++++--- src/env.rs | 3 +++ src/gitea.rs | 26 ++++++++++++++++++++++++++ src/main.rs | 19 ++++++++++--------- src/state.rs | 7 ++----- 6 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/api.rs b/src/api.rs index a62d234..e98f736 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,9 +1,10 @@ use axum::body::{Bytes, to_bytes}; -use axum::extract::{FromRef, FromRequest}; -use axum::response::{IntoResponse, Response}; +use axum::extract::{FromRef, FromRequest, State}; +use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use hmac::{Hmac, KeyInit, Mac}; +use reqwest::StatusCode; use serde_json::Value; use sha2::Sha256; use subtle::ConstantTimeEq; @@ -31,8 +32,17 @@ async fn root() -> &'static str { "Hi, i'm Herald :)" } -async fn webhook(WebhookExtract(wb): WebhookExtract) -> Result { - Ok("lol".into_response()) +async fn webhook( + State(app_state): State, + WebhookExtract(wb): WebhookExtract, +) -> Result { + app_state + .bot_tx + .send(wb) + .await + .map_err(anyhow::Error::from)?; + + Ok((StatusCode::CREATED, "Task started")) } pub struct WebhookExtract(pub WebhookType); diff --git a/src/bot.rs b/src/bot.rs index 6e9ed0f..cccdd35 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,13 +1,46 @@ -use crate::{env::EnvConfig, gitea::WebhookType}; +use crate::{ + env::EnvConfig, + errors::AppError, + gitea::{GiteaAPI, ReviewPayload, WebhookType}, +}; pub struct Bot { config: EnvConfig, + gitea_api: GiteaAPI, } impl Bot { pub fn new(config: EnvConfig) -> Self { - Self { config } + Self { + gitea_api: GiteaAPI::new(&config.gitea_url), + config, + } } - pub async fn exec(&self, webhook: WebhookType) {} + pub async fn start( + &self, + mut rx: tokio::sync::mpsc::Receiver, + ) -> 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) => self.exec_review(review_payload), + } + .await; + + match exec_result { + Ok(_) => println!("Task completed"), + Err(_) => println!("Task errored"), + } + } + + pub async fn exec_review(&self, review_payload: ReviewPayload) -> Result<(), AppError> { + Ok(()) + } } diff --git a/src/env.rs b/src/env.rs index e4a8d1a..9be6803 100644 --- a/src/env.rs +++ b/src/env.rs @@ -7,6 +7,7 @@ pub struct EnvConfig { pub webhook_secret: String, pub open_router_api_key: String, pub bot_name: String, + pub gitea_url: String, } pub fn load_config() -> anyhow::Result { @@ -16,12 +17,14 @@ pub fn load_config() -> anyhow::Result { let bot_name = try_get_env("BOT_NAME")?; let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; + let gitea_url = try_get_env("GITEA_URL")?; Ok(EnvConfig { http_port, webhook_secret, bot_name, open_router_api_key, + gitea_url, }) } diff --git a/src/gitea.rs b/src/gitea.rs index b5ab824..4e716df 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -3,6 +3,26 @@ use serde_json::Value; use crate::errors::AppError; +pub struct GiteaAPI { + base_url: String, +} + +impl GiteaAPI { + pub fn new(base_url: &str) -> Self { + Self { + base_url: String::from(base_url), + } + } + + pub async fn comment(&self, full_name: &str, index: u64) -> anyhow::Result { + Ok(1) + } + + pub async fn edit_comment(&self, full_name: &str, id: u64) -> anyhow::Result<()> { + Ok(()) + } +} + #[derive(Debug)] pub enum WebhookType { Review(ReviewPayload), @@ -12,6 +32,7 @@ pub enum WebhookType { pub struct ReviewPayload { pub action: String, pub pull_request: PullRequest, + pub repository: Repository, pub comment: Comment, } @@ -33,6 +54,11 @@ pub struct User { pub id: u64, } +#[derive(Deserialize, Debug)] +pub struct Repository { + pub full_name: String, +} + impl WebhookType { pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result { let wb = match event { diff --git a/src/main.rs b/src/main.rs index 83830ed..00b02c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,4 @@ -use std::sync::Arc; - -use tokio::sync::Mutex; - -use crate::{bot::Bot, state::AppState}; +use crate::{bot::Bot, gitea::WebhookType, state::AppState}; mod api; mod bot; @@ -15,11 +11,16 @@ mod state; #[tokio::main] async fn main() -> anyhow::Result<()> { let config = env::load_config()?; + let bot = Bot::new(config.clone()); + + let (tx, rx) = tokio::sync::mpsc::channel::(1); let app_state = AppState { - bot: Arc::new(Mutex::new(Bot::new(config.clone()))), - config: config, + bot_tx: tx, + config, }; - api::start(app_state).await -} + tokio::try_join!(bot.start(rx), api::start(app_state))?; + + Ok(()) +} \ No newline at end of file diff --git a/src/state.rs b/src/state.rs index 28c3bfe..4b9cc02 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,10 +1,7 @@ -use std::sync::Arc; -use tokio::sync::Mutex; - -use crate::{bot::Bot, env::EnvConfig}; +use crate::{env::EnvConfig, gitea::WebhookType}; #[derive(Clone)] pub struct AppState { - pub bot: Arc>, + pub bot_tx: tokio::sync::mpsc::Sender, pub config: EnvConfig, } From 4966d08d18191ab6c71af72532da613cb7c23d1b Mon Sep 17 00:00:00 2001 From: qpismont Date: Tue, 2 Jun 2026 20:30:02 +0000 Subject: [PATCH 2/9] first comment ! :D --- src/bot.rs | 20 ++++++++++++++++++-- src/env.rs | 3 +++ src/gitea.rs | 45 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index cccdd35..ac104eb 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crate::{ env::EnvConfig, errors::AppError, @@ -12,7 +14,7 @@ pub struct Bot { impl Bot { pub fn new(config: EnvConfig) -> Self { Self { - gitea_api: GiteaAPI::new(&config.gitea_url), + gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token), config, } } @@ -36,11 +38,25 @@ impl Bot { match exec_result { Ok(_) => println!("Task completed"), - Err(_) => println!("Task errored"), + Err(err) => println!("{}", err), } } pub async fn exec_review(&self, review_payload: ReviewPayload) -> Result<(), AppError> { + let new_comment = self + .gitea_api + .comment( + &review_payload.repository.full_name, + review_payload.pull_request.number, + ) + .await?; + + tokio::time::sleep(Duration::from_secs(10)).await; + + self.gitea_api + .edit_comment(&review_payload.repository.full_name, new_comment.id) + .await?; + Ok(()) } } diff --git a/src/env.rs b/src/env.rs index 9be6803..1001e62 100644 --- a/src/env.rs +++ b/src/env.rs @@ -8,6 +8,7 @@ pub struct EnvConfig { pub open_router_api_key: String, pub bot_name: String, pub gitea_url: String, + pub gitea_token: String, } pub fn load_config() -> anyhow::Result { @@ -18,6 +19,7 @@ pub fn load_config() -> anyhow::Result { let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; let gitea_url = try_get_env("GITEA_URL")?; + let gitea_token = try_get_env("GITEA_TOKEN")?; Ok(EnvConfig { http_port, @@ -25,6 +27,7 @@ pub fn load_config() -> anyhow::Result { bot_name, open_router_api_key, gitea_url, + gitea_token, }) } diff --git a/src/gitea.rs b/src/gitea.rs index 4e716df..63a816e 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,24 +1,58 @@ use serde::Deserialize; -use serde_json::Value; +use serde_json::{Value, json}; use crate::errors::AppError; pub struct GiteaAPI { base_url: String, + token: String, } impl GiteaAPI { - pub fn new(base_url: &str) -> Self { + pub fn new(base_url: &str, token: &str) -> Self { Self { base_url: String::from(base_url), + token: String::from(token), } } - pub async fn comment(&self, full_name: &str, index: u64) -> anyhow::Result { - Ok(1) + pub async fn comment(&self, full_name: &str, index: u64) -> anyhow::Result { + let url = format!( + "{}/api/v1/repos/{}/issues/{}/comments?access_token={}", + self.base_url, full_name, index, self.token + ); + + let client = reqwest::Client::new(); + let res = client + .post(url) + .json(&json!({ + "body": "Hello world :)" + })) + .send() + .await?; + + println!("{}", res.status()); + + res.json::().await.map_err(anyhow::Error::from) } - pub async fn edit_comment(&self, full_name: &str, id: u64) -> anyhow::Result<()> { + pub async fn edit_comment(&self, full_name: &str, comment_id: u64) -> anyhow::Result<()> { + let url = format!( + "{}/api/v1/repos/{}/issues/comments/{}?access_token={}", + self.base_url, full_name, comment_id, self.token + ); + + let client = reqwest::Client::new(); + let res = client + .patch(url) + .json(&json!({ + "body": "Updated Hello world :)" + })) + .send() + .await?; + + println!("{}", res.status()); + Ok(()) } } @@ -40,6 +74,7 @@ pub struct ReviewPayload { pub struct PullRequest { pub id: u64, pub diff_url: String, + pub number: u64, } #[derive(Deserialize, Debug)] From de812322011153b5f785bf58fb5980a77d6d22cc Mon Sep 17 00:00:00 2001 From: qpismont Date: Wed, 3 Jun 2026 19:38:00 +0000 Subject: [PATCH 3/9] 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. --- .devcontainer/devcontainer.json | 12 +- Cargo.lock | 332 ++++++++++++++++++++++++++++++-- Cargo.toml | 3 +- src/bot.rs | 141 +++++++++++++- src/consts.rs | 33 ++++ src/env.rs | 3 + src/gitea.rs | 23 ++- src/main.rs | 10 +- src/open_router.rs | 34 ++++ 9 files changed, 541 insertions(+), 50 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 60f004e..328bc9f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,18 +12,8 @@ "containerEnv": { "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", "workspaceFolder": "/workspaces/herald", + "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/Cargo.lock b/Cargo.lock index cc29c98..e04ae8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,72 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.11.3" @@ -249,7 +315,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -258,12 +324,30 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dotenvy_macro" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0235d912a8c749f4e0c9f18ca253b4c28cfefc1d2518096016d6e3230b6424" +dependencies = [ + "dotenvy", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -331,6 +415,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -350,7 +451,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -417,12 +522,13 @@ dependencies = [ "dotenvy", "hex", "hmac", - "reqwest", + "openrouter-rs", + "reqwest 0.13.3", "serde", "serde_json", "sha2", "subtle", - "thiserror", + "thiserror 2.0.18", "tokio", ] @@ -530,6 +636,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -639,6 +746,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -694,7 +807,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.18", "walkdir", "windows-link", ] @@ -709,7 +822,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -728,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -821,6 +934,26 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openrouter-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7101578df2f54d9013594e94367adbe656521ef6002f03e4577f1e00e57cb4" +dependencies = [ + "derive_builder", + "dotenvy_macro", + "futures-util", + "http", + "reqwest 0.12.28", + "schemars", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "urlencoding", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -903,7 +1036,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -925,7 +1058,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -998,6 +1131,67 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -1075,6 +1269,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1172,6 +1367,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1234,7 +1454,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1344,12 +1575,29 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1378,7 +1626,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1402,13 +1650,33 @@ dependencies = [ "libc", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1419,7 +1687,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1472,7 +1740,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1600,6 +1868,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1682,7 +1956,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1695,6 +1969,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.98" @@ -1724,6 +2011,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1955,7 +2251,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1976,7 +2272,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1996,7 +2292,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2036,7 +2332,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3893df5..26a473d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ reqwest = { version = "0.13", features = ["json"] } tokio = { version = "1.52", features = ["full"] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +openrouter-rs = "0.10" dotenvy = "0.15" axum = "0.8" anyhow = "1.0" @@ -16,4 +17,4 @@ hmac = "0.13" sha2 = "0.11" hex = "0.4" subtle = "2.6" -bytes = "1.11" \ No newline at end of file +bytes = "1.11" diff --git a/src/bot.rs b/src/bot.rs index ac104eb..c10bc39 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,22 +1,81 @@ -use std::time::Duration; +use serde::Deserialize; use crate::{ + consts::{BOT_PROCESS_MSG, REVIEW_PROMPT}, env::EnvConfig, errors::AppError, gitea::{GiteaAPI, ReviewPayload, WebhookType}, + open_router::OpenRouterClient, }; +#[derive(Deserialize, Debug)] +struct ReviewResult { + reviews: Vec, + comment: String, +} + +#[derive(Deserialize, Debug)] +struct ReviewItem { + filename: String, + line: Option, + code: String, + 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 { config: EnvConfig, gitea_api: GiteaAPI, + open_router_client: OpenRouterClient, } impl Bot { - pub fn new(config: EnvConfig) -> Self { - Self { + pub fn new(config: EnvConfig) -> anyhow::Result { + Ok(Self { gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token), + open_router_client: OpenRouterClient::new( + &config.open_router_api_key, + &config.open_router_model, + )?, config, - } + }) } pub async fn start( @@ -46,17 +105,87 @@ impl Bot { 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?; - tokio::time::sleep(Duration::from_secs(10)).await; + let bot_result: Result = 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); + + self.open_router_client.chat(&bot_request).await + } + .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(&review_payload.repository.full_name, new_comment.id) + .edit_comment( + &edit_msg, + &review_payload.repository.full_name, + new_comment.id, + ) .await?; Ok(()) } + + fn review_result_to_markdown(&self, result: &str) -> String { + let review_result: ReviewResult = match serde_json::from_str(result) { + Ok(review_result) => review_result, + Err(_) => { + return format!( + "Failed to parse review result. Raw output:\n\n```json\n{}\n```", + result + ); + } + }; + + 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'); + } + + md + } + + async fn download_git_diff(&self, url: &str) -> anyhow::Result { + let response = reqwest::get(url).await?; + let body = response.text().await?; + Ok(body) + } } diff --git a/src/consts.rs b/src/consts.rs index 75ebdab..9d1dce1 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,3 +1,36 @@ pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature"; 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 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}\" + + This is the git diff of the code changes: + + \"{diff}\" + + Please review the code changes and provide feedback. + Return your feedback with only this json format, reviews must contain each review (filename field must contain the full path with extension) and comment msut contain a final summary: + + { + \"reviews\": [ + { + \"filename\": \"\", + \"line\": , + \"code\": \"\", + \"message\": \"\" + } + ], + \"comment\": \"\" + } +"; diff --git a/src/env.rs b/src/env.rs index 1001e62..9ad2fa4 100644 --- a/src/env.rs +++ b/src/env.rs @@ -6,6 +6,7 @@ pub struct EnvConfig { pub http_port: u16, pub webhook_secret: String, pub open_router_api_key: String, + pub open_router_model: String, pub bot_name: String, pub gitea_url: String, pub gitea_token: String, @@ -18,6 +19,7 @@ pub fn load_config() -> anyhow::Result { let bot_name = try_get_env("BOT_NAME")?; 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_model = try_get_env("OPEN_ROUTER_MODEL")?; let gitea_url = try_get_env("GITEA_URL")?; let gitea_token = try_get_env("GITEA_TOKEN")?; @@ -26,6 +28,7 @@ pub fn load_config() -> anyhow::Result { webhook_secret, bot_name, open_router_api_key, + open_router_model, gitea_url, gitea_token, }) diff --git a/src/gitea.rs b/src/gitea.rs index 63a816e..c551b16 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -16,7 +16,12 @@ impl GiteaAPI { } } - pub async fn comment(&self, full_name: &str, index: u64) -> anyhow::Result { + pub async fn comment( + &self, + body: &str, + full_name: &str, + index: u64, + ) -> anyhow::Result { let url = format!( "{}/api/v1/repos/{}/issues/{}/comments?access_token={}", self.base_url, full_name, index, self.token @@ -26,17 +31,20 @@ impl GiteaAPI { let res = client .post(url) .json(&json!({ - "body": "Hello world :)" + "body": body })) .send() .await?; - println!("{}", res.status()); - res.json::().await.map_err(anyhow::Error::from) } - pub async fn edit_comment(&self, full_name: &str, comment_id: u64) -> anyhow::Result<()> { + pub async fn edit_comment( + &self, + body: &str, + full_name: &str, + comment_id: u64, + ) -> anyhow::Result<()> { let url = format!( "{}/api/v1/repos/{}/issues/comments/{}?access_token={}", self.base_url, full_name, comment_id, self.token @@ -46,13 +54,11 @@ impl GiteaAPI { let res = client .patch(url) .json(&json!({ - "body": "Updated Hello world :)" + "body": body })) .send() .await?; - println!("{}", res.status()); - Ok(()) } } @@ -75,6 +81,7 @@ pub struct PullRequest { pub id: u64, pub diff_url: String, pub number: u64, + pub title: String, } #[derive(Deserialize, Debug)] diff --git a/src/main.rs b/src/main.rs index 00b02c1..ad78bc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,21 +6,19 @@ mod consts; mod env; mod errors; mod gitea; +mod open_router; mod state; #[tokio::main] async fn main() -> anyhow::Result<()> { let config = env::load_config()?; - let bot = Bot::new(config.clone()); + let bot = Bot::new(config.clone())?; let (tx, rx) = tokio::sync::mpsc::channel::(1); - let app_state = AppState { - bot_tx: tx, - config, - }; + let app_state = AppState { bot_tx: tx, config }; tokio::try_join!(bot.start(rx), api::start(app_state))?; Ok(()) -} \ No newline at end of file +} diff --git a/src/open_router.rs b/src/open_router.rs index e69de29..017b9b2 100644 --- a/src/open_router.rs +++ b/src/open_router.rs @@ -0,0 +1,34 @@ +use openrouter_rs::{Message, api::chat::ChatCompletionRequest}; + +pub struct OpenRouterClient { + client: openrouter_rs::OpenRouterClient, + model: String, +} + +impl OpenRouterClient { + pub fn new(token: &str, model: &str) -> anyhow::Result { + Ok(Self { + client: openrouter_rs::OpenRouterClient::builder() + .api_key(token) + .build()?, + model: String::from(model), + }) + } + + pub async fn chat(&self, msg: &str) -> anyhow::Result { + let request = ChatCompletionRequest::builder() + .model(&self.model) + .messages(vec![Message::new( + openrouter_rs::types::Role::Developer, + msg, + )]) + .build()?; + + let response = self.client.chat().create(&request).await?; + + response.choices[0] + .content() + .map(|msg| String::from(msg)) + .ok_or(anyhow::anyhow!("No content")) + } +} From cd5c5b9478eec2e81a64e4d9db8f50e16ca9b486 Mon Sep 17 00:00:00 2001 From: qpismont Date: Wed, 3 Jun 2026 20:51:21 +0000 Subject: [PATCH 4/9] 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. --- Cargo.lock | 435 +-------------------------------------------- Cargo.toml | 2 +- src/bot.rs | 5 +- src/consts.rs | 12 +- src/env.rs | 6 + src/gitea.rs | 22 ++- src/open_router.rs | 10 +- 7 files changed, 46 insertions(+), 446 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e04ae8a..2a4e2db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,28 +14,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.9" @@ -128,8 +106,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -145,63 +121,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.3.0" @@ -336,33 +267,12 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -394,12 +304,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures-channel" version = "0.3.32" @@ -487,31 +391,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "herald" version = "0.1.0" @@ -523,7 +402,7 @@ dependencies = [ "hex", "hmac", "openrouter-rs", - "reqwest 0.13.3", + "reqwest", "serde", "serde_json", "sha2", @@ -611,7 +490,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -657,11 +535,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -773,16 +649,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -795,65 +661,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.117", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.98" @@ -944,7 +751,7 @@ dependencies = [ "dotenvy_macro", "futures-util", "http", - "reqwest 0.12.28", + "reqwest", "schemars", "serde", "serde_json", @@ -954,12 +761,6 @@ dependencies = [ "urlencoding", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1048,7 +849,6 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -1192,46 +992,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "reqwest" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "ring" version = "0.17.14" @@ -1252,22 +1012,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustls" version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1276,18 +1026,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1298,40 +1036,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1349,24 +1059,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "1.2.1" @@ -1398,35 +1090,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.228" @@ -1531,22 +1194,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "slab" version = "0.4.12" @@ -1629,27 +1276,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -1880,16 +1506,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -2002,15 +1618,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.7" @@ -2020,50 +1627,12 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 26a473d..2dba50b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -reqwest = { version = "0.13", features = ["json"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1.52", features = ["full"] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } diff --git a/src/bot.rs b/src/bot.rs index c10bc39..305ac6f 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -69,10 +69,11 @@ pub struct Bot { impl Bot { pub fn new(config: EnvConfig) -> anyhow::Result { Ok(Self { - gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token), + gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token, config.gitea_timeout), open_router_client: OpenRouterClient::new( &config.open_router_api_key, &config.open_router_model, + config.open_router_timeout, )?, config, }) @@ -116,7 +117,7 @@ impl Bot { .download_git_diff(&review_payload.pull_request.diff_url) .await?; - let bot_request = &&REVIEW_PROMPT + let bot_request = REVIEW_PROMPT .replace("{subject}", &review_payload.pull_request.title) .replace("{comment}", &review_payload.comment.body) .replace("{diff}", &git_diff); diff --git a/src/consts.rs b/src/consts.rs index 9d1dce1..4eb1b41 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -20,7 +20,17 @@ pub const REVIEW_PROMPT: &str = " \"{diff}\" Please review the code changes and provide feedback. - Return your feedback with only this json format, reviews must contain each review (filename field must contain the full path with extension) and comment msut contain a final summary: + + 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. + + Return your feedback, in french, with only this json format, reviews must contain each review + (filename field must contain the full path with extension) and comment must contain a final summary: { \"reviews\": [ diff --git a/src/env.rs b/src/env.rs index 9ad2fa4..6d013be 100644 --- a/src/env.rs +++ b/src/env.rs @@ -7,9 +7,11 @@ pub struct EnvConfig { pub webhook_secret: String, pub open_router_api_key: String, pub open_router_model: String, + pub open_router_timeout: u64, pub bot_name: String, pub gitea_url: String, pub gitea_token: String, + pub gitea_timeout: u64, } pub fn load_config() -> anyhow::Result { @@ -20,8 +22,10 @@ pub fn load_config() -> anyhow::Result { 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_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 { http_port, @@ -29,8 +33,10 @@ pub fn load_config() -> anyhow::Result { bot_name, open_router_api_key, open_router_model, + open_router_timeout, gitea_url, gitea_token, + gitea_timeout, }) } diff --git a/src/gitea.rs b/src/gitea.rs index c551b16..c8bff4a 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use serde::Deserialize; use serde_json::{Value, json}; @@ -5,13 +7,18 @@ use crate::errors::AppError; pub struct GiteaAPI { base_url: String, + client: reqwest::Client, token: String, } impl GiteaAPI { - pub fn new(base_url: &str, token: &str) -> Self { + pub fn new(base_url: &str, token: &str, timeout: u64) -> Self { Self { base_url: String::from(base_url), + client: reqwest::Client::builder() + .timeout(Duration::from_secs(timeout)) + .build() + .unwrap(), token: String::from(token), } } @@ -27,8 +34,8 @@ impl GiteaAPI { self.base_url, full_name, index, self.token ); - let client = reqwest::Client::new(); - let res = client + let res = self + .client .post(url) .json(&json!({ "body": body @@ -50,16 +57,15 @@ impl GiteaAPI { self.base_url, full_name, comment_id, self.token ); - let client = reqwest::Client::new(); - let res = client + self.client .patch(url) .json(&json!({ "body": body })) .send() - .await?; - - Ok(()) + .await + .map(|_| ()) + .map_err(anyhow::Error::from) } } diff --git a/src/open_router.rs b/src/open_router.rs index 017b9b2..cb8dbfa 100644 --- a/src/open_router.rs +++ b/src/open_router.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use openrouter_rs::{Message, api::chat::ChatCompletionRequest}; pub struct OpenRouterClient { @@ -6,10 +8,15 @@ pub struct OpenRouterClient { } impl OpenRouterClient { - pub fn new(token: &str, model: &str) -> anyhow::Result { + pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result { 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), }) @@ -18,6 +25,7 @@ impl OpenRouterClient { pub async fn chat(&self, msg: &str) -> anyhow::Result { let request = ChatCompletionRequest::builder() .model(&self.model) + .enable_reasoning() .messages(vec![Message::new( openrouter_rs::types::Role::Developer, msg, From 01e13f0081fb1a775a45a5fd2e789d9762f3374e Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 5 Jun 2026 18:39:38 +0000 Subject: [PATCH 5/9] Add default authorization header for gitea api (remove query string) Add review cost --- src/bot.rs | 46 ++++++++++++++++++++++++---------------------- src/consts.rs | 1 + src/gitea.rs | 21 ++++++++++++++------- src/open_router.rs | 23 ++++++++++++++--------- 4 files changed, 53 insertions(+), 38 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 305ac6f..413de1e 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -9,17 +9,18 @@ use crate::{ }; #[derive(Deserialize, Debug)] -struct ReviewResult { - reviews: Vec, - comment: String, +pub struct ReviewResult { + pub reviews: Vec, + pub comment: String, + pub cost: Option, } #[derive(Deserialize, Debug)] -struct ReviewItem { - filename: String, - line: Option, - code: String, - message: String, +pub struct ReviewItem { + pub filename: String, + pub line: Option, + pub code: String, + pub message: String, } /// Map a filename to a markdown language identifier for syntax highlighting. @@ -69,7 +70,7 @@ pub struct Bot { impl Bot { pub fn new(config: EnvConfig) -> anyhow::Result { Ok(Self { - gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token, config.gitea_timeout), + gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token, config.gitea_timeout)?, open_router_client: OpenRouterClient::new( &config.open_router_api_key, &config.open_router_model, @@ -112,7 +113,7 @@ impl Bot { ) .await?; - let bot_result: Result = async { + let bot_result: Result = async { let git_diff = self .download_git_diff(&review_payload.pull_request.diff_url) .await?; @@ -122,7 +123,12 @@ impl Bot { .replace("{comment}", &review_payload.comment.body) .replace("{diff}", &git_diff); - self.open_router_client.chat(&bot_request).await + let chat_result = self.open_router_client.chat(&bot_request).await?; + let mut review_result = serde_json::from_str::(&chat_result.message)?; + + review_result.cost = chat_result.cost; + + Ok(review_result) } .await; @@ -142,17 +148,7 @@ impl Bot { Ok(()) } - fn review_result_to_markdown(&self, result: &str) -> String { - let review_result: ReviewResult = match serde_json::from_str(result) { - Ok(review_result) => review_result, - Err(_) => { - return format!( - "Failed to parse review result. Raw output:\n\n```json\n{}\n```", - result - ); - } - }; - + fn review_result_to_markdown(&self, review_result: &ReviewResult) -> String { if review_result.reviews.is_empty() { return String::from("No issues found. ✅"); } @@ -181,6 +177,12 @@ impl Bot { 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 } diff --git a/src/consts.rs b/src/consts.rs index 4eb1b41..ded647d 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -30,6 +30,7 @@ pub const REVIEW_PROMPT: &str = " 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 + All fields are mandatory. (filename field must contain the full path with extension) and comment must contain a final summary: { diff --git a/src/gitea.rs b/src/gitea.rs index c8bff4a..4f81913 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -12,15 +12,22 @@ pub struct GiteaAPI { } impl GiteaAPI { - pub fn new(base_url: &str, token: &str, timeout: u64) -> Self { - Self { + pub fn new(base_url: &str, token: &str, timeout: u64) -> anyhow::Result { + 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() .unwrap(), token: String::from(token), - } + }) } pub async fn comment( @@ -30,8 +37,8 @@ impl GiteaAPI { index: u64, ) -> anyhow::Result { let url = format!( - "{}/api/v1/repos/{}/issues/{}/comments?access_token={}", - self.base_url, full_name, index, self.token + "{}/api/v1/repos/{}/issues/{}/comments", + self.base_url, full_name, index ); let res = self @@ -53,8 +60,8 @@ impl GiteaAPI { comment_id: u64, ) -> anyhow::Result<()> { let url = format!( - "{}/api/v1/repos/{}/issues/comments/{}?access_token={}", - self.base_url, full_name, comment_id, self.token + "{}/api/v1/repos/{}/issues/comments/{}", + self.base_url, full_name, comment_id ); self.client diff --git a/src/open_router.rs b/src/open_router.rs index cb8dbfa..b5602d5 100644 --- a/src/open_router.rs +++ b/src/open_router.rs @@ -2,6 +2,11 @@ use std::time::Duration; use openrouter_rs::{Message, api::chat::ChatCompletionRequest}; +pub struct ChatResult { + pub message: String, + pub cost: Option, +} + pub struct OpenRouterClient { client: openrouter_rs::OpenRouterClient, model: String, @@ -22,21 +27,21 @@ impl OpenRouterClient { }) } - pub async fn chat(&self, msg: &str) -> anyhow::Result { + pub async fn chat(&self, msg: &str) -> anyhow::Result { let request = ChatCompletionRequest::builder() .model(&self.model) .enable_reasoning() - .messages(vec![Message::new( - openrouter_rs::types::Role::Developer, - msg, - )]) + .messages(vec![Message::new(openrouter_rs::types::Role::User, msg)]) .build()?; let response = self.client.chat().create(&request).await?; - response.choices[0] - .content() - .map(|msg| String::from(msg)) - .ok_or(anyhow::anyhow!("No content")) + 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), + }) } } From 3501e4ae9d98cdf8e9727365ce543eb5885a283a Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 5 Jun 2026 18:48:02 +0000 Subject: [PATCH 6/9] Use reqwest client with timeout in gitea.rs and bot.rs --- src/bot.rs | 15 ++++++++++++--- src/gitea.rs | 5 +---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 413de1e..72b80ab 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use serde::Deserialize; use crate::{ @@ -65,18 +67,25 @@ pub struct Bot { config: EnvConfig, gitea_api: GiteaAPI, open_router_client: OpenRouterClient, + http_client: reqwest::Client, } impl Bot { pub fn new(config: EnvConfig) -> anyhow::Result { + 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, config.gitea_timeout)?, + 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, - config.open_router_timeout, + open_router_timeout, )?, config, + http_client: reqwest::Client::builder() + .timeout(Duration::from_secs(gitea_timeout)) + .build()?, }) } @@ -187,7 +196,7 @@ impl Bot { } async fn download_git_diff(&self, url: &str) -> anyhow::Result { - let response = reqwest::get(url).await?; + let response = self.http_client.get(url).send().await?; let body = response.text().await?; Ok(body) } diff --git a/src/gitea.rs b/src/gitea.rs index 4f81913..4da05b4 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -8,7 +8,6 @@ use crate::errors::AppError; pub struct GiteaAPI { base_url: String, client: reqwest::Client, - token: String, } impl GiteaAPI { @@ -24,9 +23,7 @@ impl GiteaAPI { client: reqwest::Client::builder() .timeout(Duration::from_secs(timeout)) .default_headers(default_headers) - .build() - .unwrap(), - token: String::from(token), + .build()?, }) } From 6aa653e84647c3fc6e2f169c84d1df15dc6c2bd9 Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 5 Jun 2026 18:52:59 +0000 Subject: [PATCH 7/9] Add http status check for gitea api --- src/gitea.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/gitea.rs b/src/gitea.rs index 4da05b4..1a3581e 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -47,6 +47,10 @@ impl GiteaAPI { .send() .await?; + if !res.status().is_success() { + return Err(anyhow::anyhow!("Failed to comment: {}", res.status())); + } + res.json::().await.map_err(anyhow::Error::from) } @@ -61,15 +65,20 @@ impl GiteaAPI { self.base_url, full_name, comment_id ); - self.client + let res = self + .client .patch(url) .json(&json!({ "body": body })) .send() - .await - .map(|_| ()) - .map_err(anyhow::Error::from) + .await?; + + if !res.status().is_success() { + return Err(anyhow::anyhow!("Failed to comment: {}", res.status())); + } + + Ok(()) } } From ced1fca5634aeda98caade60ae8b6b6cb89731de Mon Sep 17 00:00:00 2001 From: qpismont Date: Fri, 5 Jun 2026 19:34:29 +0000 Subject: [PATCH 8/9] Add gitea download git diff limit --- Cargo.lock | 14 ++++++++++++++ Cargo.toml | 3 +++ src/bot.rs | 23 ++++++++++++++++++----- src/consts.rs | 2 ++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a4e2db..da87dd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,6 +399,7 @@ dependencies = [ "axum", "bytes", "dotenvy", + "futures-util", "hex", "hmac", "openrouter-rs", @@ -409,6 +410,8 @@ dependencies = [ "subtle", "thiserror 2.0.18", "tokio", + "tokio-stream", + "tokio-util", ] [[package]] @@ -1379,6 +1382,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" diff --git a/Cargo.toml b/Cargo.toml index 2dba50b..d036f9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ edition = "2024" [dependencies] reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1.52", features = ["full"] } +tokio-stream = "0.1" +tokio-util = "0.7" +futures-util = "0.3" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } openrouter-rs = "0.10" diff --git a/src/bot.rs b/src/bot.rs index 72b80ab..d03d2fd 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,9 +1,11 @@ -use std::time::Duration; - +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, REVIEW_PROMPT}, + consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, env::EnvConfig, errors::AppError, gitea::{GiteaAPI, ReviewPayload, WebhookType}, @@ -197,7 +199,18 @@ impl Bot { async fn download_git_diff(&self, url: &str) -> anyhow::Result { let response = self.http_client.get(url).send().await?; - let body = response.text().await?; - Ok(body) + 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()) } } diff --git a/src/consts.rs b/src/consts.rs index ded647d..f3ae2dd 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,6 +1,8 @@ pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature"; 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_DIFF_SIZE: usize = 1024 * 1024; // 1 MiB pub const BOT_PROCESS_MSG: &str = " Review in progress with the model \"{model}\"... From aa0dbdcc7a59ac91f3ddef4931e45468dcf5761b Mon Sep 17 00:00:00 2001 From: qpismont Date: Sat, 6 Jun 2026 17:27:35 +0000 Subject: [PATCH 9/9] 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. --- src/bot.rs | 115 ++-------------------- src/bot_actions/mod.rs | 1 + src/bot_actions/review.rs | 201 ++++++++++++++++++++++++++++++++++++++ src/consts.rs | 13 +-- src/gitea.rs | 51 +++++++++- src/main.rs | 1 + 6 files changed, 265 insertions(+), 117 deletions(-) create mode 100644 src/bot_actions/mod.rs create mode 100644 src/bot_actions/review.rs diff --git a/src/bot.rs b/src/bot.rs index d03d2fd..bd9b62c 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -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 = 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::(&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 { - 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()) - } } diff --git a/src/bot_actions/mod.rs b/src/bot_actions/mod.rs new file mode 100644 index 0000000..0755bd4 --- /dev/null +++ b/src/bot_actions/mod.rs @@ -0,0 +1 @@ +pub mod review; diff --git a/src/bot_actions/review.rs b/src/bot_actions/review.rs new file mode 100644 index 0000000..8ffc3b1 --- /dev/null +++ b/src/bot_actions/review.rs @@ -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 = 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::(&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 { + 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 { + let plus_part = hunk_header.split('+').nth(1)?; + let num_str = plus_part.split(|c: char| !c.is_ascii_digit()).next()?; + num_str.parse::().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); +} diff --git a/src/consts.rs b/src/consts.rs index f3ae2dd..9a38974 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -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. diff --git a/src/gitea.rs b/src/gitea.rs index 1a3581e..5a725ea 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -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::>(); + + 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)] diff --git a/src/main.rs b/src/main.rs index ad78bc0..f1a8ad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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;