Merge pull request 'started gitea api impl' (#2) from gitea_api into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-06-07 10:23:31 +02: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
+5 -1
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"
+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,
} }