use std::time::Duration; use futures_util::stream::TryStreamExt; use serde::Deserialize; use serde_json::{Value, json}; use tokio::io::AsyncReadExt; use tokio_util::io::StreamReader; use tracing::{instrument, warn}; use crate::{ bot::{ReviewResult, ReviewSeverity, ReviewSide}, consts::MAX_DIFF_SIZE, errors::AppError, }; /// Page size requested when listing the files of a pull request. const FILE_PAGE_SIZE: u64 = 50; /// Maximum number of pages fetched for a pull request file list. const MAX_FILE_PAGES: u64 = 10; #[derive(Clone)] pub struct GiteaAPI { base_url: String, token: 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), token: String::from(token), client: reqwest::Client::builder() .timeout(Duration::from_secs(timeout)) .default_headers(default_headers) .build()?, }) } /// API token used to authenticate against Gitea. pub fn token(&self) -> &str { &self.token } #[instrument(skip(self))] pub async fn get_authorized_user(&self) -> anyhow::Result { let url = format!("{}/api/v1/user", self.base_url); let res = self.client.get(url).send().await?; if !res.status().is_success() { return Err(anyhow::anyhow!( "Failed to get authorized user: {}", res.status() )); } res.json::().await.map_err(anyhow::Error::from) } #[instrument(skip(self))] 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) } #[instrument(skip(self))] 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(()) } #[instrument(skip(self))] pub async fn delete_comment(&self, 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.delete(url).send().await?; if !res.status().is_success() { return Err(anyhow::anyhow!( "Failed to delete comment: {}", res.status() )); } Ok(()) } /// Raw unified diff of a pull request. /// /// The API endpoint is used rather than the `diff_url` carried by the /// webhook: that one points at a web route, which is session authenticated /// and therefore does not serve private repositories to an API token. #[instrument(skip(self))] pub async fn pull_request_diff(&self, full_name: &str, index: u64) -> anyhow::Result { let url = format!( "{}/api/v1/repos/{}/pulls/{}.diff", self.base_url, full_name, index ); let res = self.client.get(url).send().await?; if !res.status().is_success() { return Err(anyhow::anyhow!( "Failed to download pull request diff: {}", res.status() )); } read_capped(res).await } /// Files changed by a pull request, with their exact path and status. #[instrument(skip(self))] pub async fn pull_request_files( &self, full_name: &str, index: u64, ) -> anyhow::Result> { let mut files = Vec::new(); for page in 1..=MAX_FILE_PAGES { let url = format!( "{}/api/v1/repos/{}/pulls/{}/files?limit={FILE_PAGE_SIZE}&page={page}", self.base_url, full_name, index ); let res = self.client.get(url).send().await?; if !res.status().is_success() { return Err(anyhow::anyhow!( "Failed to list pull request files: {}", res.status() )); } let page_files = res.json::>().await?; // The instance may cap the page size below the requested one, so a // short page is not the end of the list: an empty one is. if page_files.is_empty() { return Ok(files); } files.extend(page_files); } warn!(files = files.len(), "Pull request file list was truncated"); Ok(files) } #[instrument(skip(self, review_result))] pub async fn post_pull_request_review( &self, review_result: &ReviewResult, final_comment: &str, 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_map(|review| { let line = review.line?; let path = review.filename.clone(); let severity = review .severity .unwrap_or(ReviewSeverity::Maintainability) .label(); let body = format!("**[{severity}]** {}", review.message); // A line removed by the pull request only exists in the old // version of the file, so it is anchored with `old_position`. Some(match review.side { Some(ReviewSide::Removed) => json!({ "path": path, "old_position": line, "body": body }), _ => json!({ "path": path, "new_position": line, "body": body }), }) }) .collect::>(); let res = self .client .post(url) .json(&json!({ "event": "COMMENT", "body": final_comment, "comments": comments })) .send() .await?; if !res.status().is_success() { return Err(anyhow::anyhow!("Failed to post review: {}", res.status())); } Ok(()) } } /// Reads a response body, refusing to buffer more than [`MAX_DIFF_SIZE`]. async fn read_capped(response: reqwest::Response) -> anyhow::Result { 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!("Pull request diff exceeds the maximum allowed size of 1 MiB"); } Ok(String::from_utf8_lossy(&buf).into_owned()) } #[derive(Debug)] pub enum WebhookType { Review(ReviewPayload), } impl WebhookType { pub fn event_id(&self) -> u64 { match self { WebhookType::Review(payload) => payload.comment.id, } } pub fn event_type_str(&self) -> &'static str { match self { WebhookType::Review(_) => "review", } } } #[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 number: u64, pub title: String, } #[derive(Deserialize, Debug)] pub struct Comment { pub id: u64, pub body: String, } #[derive(Deserialize, Debug)] pub struct User { pub login: String, } #[derive(Deserialize, Debug)] pub struct Repository { pub full_name: String, pub clone_url: String, } /// A file changed by a pull request, as reported by the API. #[derive(Deserialize, Debug)] pub struct PullRequestFile { /// Path of the file in the new version of the repository. pub filename: String, /// Previous path, for a renamed file. #[serde(default)] pub previous_filename: Option, /// `added`, `modified`, `deleted`, `renamed`… pub status: 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 action = match &wb { WebhookType::Review(review_payload) => &review_payload.action, }; if action != "created" { return Err(AppError::InvalidActionErr); } 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); } 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", "number": 1, "title": "My PR" }, "repository": { "full_name": "owner/repo", "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 7, "body": "@test_bot LGTM", "user": { "id": 100, "login": "test_user" } } }); 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.comment.id, 7); assert_eq!(payload.comment.body, "@test_bot LGTM"); } } } #[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", "number": 1, "title": "My PR" }, "repository": { "full_name": "owner/repo", "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, "body": "@test_bot body", "user": { "id": 1, "login": "test_user" } } }); 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", "number": 5, "title": "My PR" }, "repository": { "full_name": "owner/repo", "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 12, "body": "Needs work", "user": { "id": 200, "login": "test_user" } } }); let payload: ReviewPayload = serde_json::from_value(json).unwrap(); assert_eq!(payload.action, "created"); assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.body, "Needs work"); assert_eq!( payload.repository.clone_url, "https://github.com/owner/repo.git" ); } #[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", "number": 1, "title": "My PR" }, "repository": { "full_name": "owner/repo", "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, "body": "@other_bot do something", "user": { "id": 1, "login": "test_user" } } }); 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", "number": 1, "title": "My PR" }, "repository": { "full_name": "owner/repo", "clone_url": "https://github.com/owner/repo.git" }, "comment": { "id": 1, "body": "just a comment without bot mention", "user": { "id": 1, "login": "test_user" } } }); let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr)); } }