Compare commits
10 Commits
1f60f6572f
...
3984a7d3ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 3984a7d3ba | |||
| aa0dbdcc7a | |||
| ced1fca563 | |||
| 6aa653e846 | |||
| 3501e4ae9d | |||
| 01e13f0081 | |||
| cd5c5b9478 | |||
| de81232201 | |||
| 4966d08d18 | |||
| 10ebee389e |
@@ -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
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod review;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
use futures_util::stream::TryStreamExt;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
bot::ReviewResult,
|
||||||
|
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT},
|
||||||
|
errors::AppError,
|
||||||
|
gitea::{GiteaAPI, ReviewPayload},
|
||||||
|
open_router::OpenRouterClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn exec_review(
|
||||||
|
gitea_api: &GiteaAPI,
|
||||||
|
open_router_client: &OpenRouterClient,
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
model: &str,
|
||||||
|
review_payload: ReviewPayload,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let new_comment = gitea_api
|
||||||
|
.comment(
|
||||||
|
&BOT_PROCESS_MSG.replace("{model}", model),
|
||||||
|
&review_payload.repository.full_name,
|
||||||
|
review_payload.pull_request.number,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let bot_result: Result<ReviewResult, anyhow::Error> = async {
|
||||||
|
let git_diff =
|
||||||
|
download_git_diff(&http_client, &review_payload.pull_request.diff_url).await?;
|
||||||
|
|
||||||
|
let diff_for_llm = format_diff_for_review(&git_diff);
|
||||||
|
|
||||||
|
let bot_request = REVIEW_PROMPT
|
||||||
|
.replace("{subject}", &review_payload.pull_request.title)
|
||||||
|
.replace("{comment}", &review_payload.comment.body)
|
||||||
|
.replace("{diff}", &diff_for_llm);
|
||||||
|
|
||||||
|
let chat_result = open_router_client.chat(&bot_request).await?;
|
||||||
|
let mut review_result = serde_json::from_str::<ReviewResult>(&chat_result.message)?;
|
||||||
|
|
||||||
|
review_result.cost = chat_result.cost;
|
||||||
|
|
||||||
|
gitea_api
|
||||||
|
.post_pull_request_review(
|
||||||
|
&review_result,
|
||||||
|
&review_payload.repository.full_name,
|
||||||
|
review_payload.pull_request.number,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(review_result)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let edit_msg = match bot_result {
|
||||||
|
Ok(bot_result) => review_result_to_markdown(&bot_result),
|
||||||
|
Err(e) => format!("Error while reviewing: {}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
gitea_api
|
||||||
|
.edit_comment(
|
||||||
|
&edit_msg,
|
||||||
|
&review_payload.repository.full_name,
|
||||||
|
new_comment.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
||||||
|
if review_result.reviews.is_empty() {
|
||||||
|
return String::from("No issues found. ✅");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut md = String::from("## Review Feedback\n\n");
|
||||||
|
|
||||||
|
md.push_str(&format!(
|
||||||
|
"### {} issues found.\n\n",
|
||||||
|
review_result.reviews.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
if !review_result.comment.is_empty() {
|
||||||
|
md.push_str("\n---\n\n");
|
||||||
|
md.push_str("### Summary\n\n");
|
||||||
|
md.push_str(&review_result.comment);
|
||||||
|
md.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cost) = review_result.cost {
|
||||||
|
md.push_str("\n---\n\n");
|
||||||
|
md.push_str(&format!("### Cost: ${}", cost));
|
||||||
|
md.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
md
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_git_diff(http_client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
|
||||||
|
let response = http_client.get(url).send().await?;
|
||||||
|
let stream = response.bytes_stream().map_err(std::io::Error::other);
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(MAX_DIFF_SIZE);
|
||||||
|
StreamReader::new(stream)
|
||||||
|
.take((MAX_DIFF_SIZE + 1) as u64)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if buf.len() > MAX_DIFF_SIZE {
|
||||||
|
anyhow::bail!("Git diff exceeds the maximum allowed size of 1 Mo");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&buf).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_diff_for_review(git_diff: &str) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut current_file: Option<&str> = None;
|
||||||
|
let mut new_line: u64 = 0;
|
||||||
|
|
||||||
|
for line in git_diff.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("diff --git a/") {
|
||||||
|
if let Some(end) = rest.find(' ') {
|
||||||
|
current_file = Some(&rest[..end]);
|
||||||
|
}
|
||||||
|
new_line = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.starts_with("---") || line.starts_with("+++") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.starts_with("@@") && line.contains('+') {
|
||||||
|
if let Some(start) = parse_hunk_new_start(line) {
|
||||||
|
new_line = start;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(filename) = current_file else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if line.starts_with(' ') {
|
||||||
|
new_line += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(code) = line.strip_prefix('+') {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = writeln!(output, "{filename}:{new_line}:{code}");
|
||||||
|
new_line += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hunk_new_start(hunk_header: &str) -> Option<u64> {
|
||||||
|
let plus_part = hunk_header.split('+').nth(1)?;
|
||||||
|
let num_str = plus_part.split(|c: char| !c.is_ascii_digit()).next()?;
|
||||||
|
num_str.parse::<u64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[test]
|
||||||
|
fn test_format_diff_for_review() {
|
||||||
|
let diff = concat!(
|
||||||
|
"diff --git a/src/foo.rs b/src/foo.rs\n",
|
||||||
|
"--- a/src/foo.rs\n",
|
||||||
|
"+++ b/src/foo.rs\n",
|
||||||
|
"@@ -1,3 +1,6 @@\n",
|
||||||
|
" fn main() {\n",
|
||||||
|
"+ let x = 1;\n",
|
||||||
|
" println!(\"hello\");\n",
|
||||||
|
"+ let y = 2;\n",
|
||||||
|
"+ let z = 3;\n",
|
||||||
|
" }\n",
|
||||||
|
"diff --git a/src/bar.rs b/src/bar.rs\n",
|
||||||
|
"--- a/src/bar.rs\n",
|
||||||
|
"+++ b/src/bar.rs\n",
|
||||||
|
"@@ -10,4 +10,6 @@\n",
|
||||||
|
" old context\n",
|
||||||
|
"+ let a = 10;\n",
|
||||||
|
" more context\n",
|
||||||
|
"+ let b = 20;\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = format_diff_for_review(diff);
|
||||||
|
let expected = concat!(
|
||||||
|
"src/foo.rs:2: let x = 1;\n",
|
||||||
|
"src/foo.rs:4: let y = 2;\n",
|
||||||
|
"src/foo.rs:5: let z = 3;\n",
|
||||||
|
"src/bar.rs:11: let a = 10;\n",
|
||||||
|
"src/bar.rs:13: let b = 20;\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user