use std::time::Duration; 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 { 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 { 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::().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::>(); 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)] pub enum WebhookType { Review(ReviewPayload), } #[derive(Deserialize, Debug)] pub struct ReviewPayload { pub action: String, pub pull_request: PullRequest, pub repository: Repository, pub comment: Comment, } #[derive(Deserialize, Debug)] pub struct PullRequest { pub id: u64, pub diff_url: String, pub number: u64, pub title: String, } #[derive(Deserialize, Debug)] pub struct Comment { pub id: u64, pub body: String, pub user: User, } #[derive(Deserialize, Debug)] 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 { "pull_request_comment" => Ok(WebhookType::Review(serde_json::from_value(json)?)), _ => Err(AppError::UnknownEventErr), }?; let pr_body = match &wb { WebhookType::Review(review_payload) => &review_payload.comment.body, }; if !pr_body.starts_with(&format!("@{}", bot_name)) { return Err(AppError::UnauthorizedUserErr); } let action = match &wb { WebhookType::Review(review_payload) => &review_payload.action, }; if action != "created" { return Err(AppError::InvalidActionErr); } Ok(wb) } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn test_from_event_valid_pull_request_comment() { let json = json!({ "action": "created", "pull_request": { "id": 42, "diff_url": "https://mydiff.fr" }, "comment": { "id": 7, "body": "@test_bot LGTM", "user": { "id": 100 } } }); let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(result.is_ok()); match result.unwrap() { WebhookType::Review(payload) => { assert_eq!(payload.action, "created"); assert_eq!(payload.pull_request.id, 42); assert_eq!(payload.comment.id, 7); assert_eq!(payload.comment.body, "@test_bot LGTM"); assert_eq!(payload.comment.user.id, 100); } } } #[test] fn test_from_event_unknown_event() { let json = json!({}); let result = WebhookType::from_event("push", "test_bot", json); assert!(result.is_err()); match result.unwrap_err() { AppError::UnknownEventErr => {} _ => panic!("expected UnknownEventErr"), } } #[test] fn test_from_event_malformed_json() { let json = json!({ "action": "created" // pull_request and comment are missing }); let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(result.is_err()); match result.unwrap_err() { AppError::BadJsonStructErr(_) => {} _ => panic!("expected BadJsonStructErr"), } } #[test] fn test_from_event_rejects_non_created_action() { let json = json!({ "action": "edited", "pull_request": { "id": 1, "diff_url": "https://mydiff.fr" }, "comment": { "id": 1, "body": "@test_bot body", "user": { "id": 1 } } }); let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(result.is_err()); match result.unwrap_err() { AppError::InvalidActionErr => {} _ => panic!("expected InvalidActionErr"), } } #[test] fn test_deserialize_review_payload() { let json = json!({ "action": "created", "pull_request": { "id": 99, "diff_url": "https://mydiff.fr" }, "comment": { "id": 12, "body": "Needs work", "user": { "id": 200 } } }); let payload: ReviewPayload = serde_json::from_value(json).unwrap(); assert_eq!(payload.action, "created"); assert_eq!(payload.pull_request.id, 99); assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.body, "Needs work"); assert_eq!(payload.comment.user.id, 200); } #[test] fn test_from_event_empty_json() { let result = WebhookType::from_event("pull_request_comment", "test_bot", json!({})); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), AppError::BadJsonStructErr(_))); } #[test] fn test_from_event_rejects_wrong_bot_name() { let json = json!({ "action": "created", "pull_request": { "id": 1, "diff_url": "https://mydiff.fr" }, "comment": { "id": 1, "body": "@other_bot do something", "user": { "id": 1 } } }); let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr)); } #[test] fn test_from_event_rejects_no_bot_prefix() { let json = json!({ "action": "created", "pull_request": { "id": 1, "diff_url": "https://mydiff.fr" }, "comment": { "id": 1, "body": "just a comment without bot mention", "user": { "id": 1 } } }); let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr)); } }