use compliance_core::error::CoreError; use compliance_core::models::{TrackerIssue, TrackerType}; use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment}; use secrecy::{ExposeSecret, SecretString}; pub struct GiteaTracker { base_url: String, http: reqwest::Client, token: SecretString, } impl GiteaTracker { pub fn new(base_url: String, token: SecretString) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), http: reqwest::Client::new(), token, } } fn api_url(&self, path: &str) -> String { format!("{}/api/v1{}", self.base_url, path) } } impl IssueTracker for GiteaTracker { fn name(&self) -> &str { "gitea" } async fn create_issue( &self, owner: &str, repo: &str, title: &str, body: &str, labels: &[String], ) -> Result { let url = self.api_url(&format!("/repos/{owner}/{repo}/issues")); // Gitea expects label IDs (integers), not names. Append label names // to the body instead since resolving IDs would require extra API calls. let mut full_body = body.to_string(); if !labels.is_empty() { full_body.push_str("\n\n**Labels:** "); full_body.push_str(&labels.join(", ")); } let payload = serde_json::json!({ "title": title, "body": full_body, }); let resp = self .http .post(&url) .header( "Authorization", format!("token {}", self.token.expose_secret()), ) .json(&payload) .send() .await .map_err(|e| CoreError::IssueTracker(format!("Gitea create issue failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(CoreError::IssueTracker(format!( "Gitea returned {status}: {text}" ))); } let issue: serde_json::Value = resp .json() .await .map_err(|e| CoreError::IssueTracker(format!("Failed to parse Gitea response: {e}")))?; Ok(TrackerIssue::new( String::new(), TrackerType::Gitea, issue["number"].to_string(), issue["html_url"].as_str().unwrap_or("").to_string(), title.to_string(), )) } async fn update_issue_status( &self, owner: &str, repo: &str, external_id: &str, status: &str, ) -> Result<(), CoreError> { let url = self.api_url(&format!("/repos/{owner}/{repo}/issues/{external_id}")); let state = match status { "closed" | "resolved" => "closed", _ => "open", }; let resp = self .http .patch(&url) .header( "Authorization", format!("token {}", self.token.expose_secret()), ) .json(&serde_json::json!({ "state": state })) .send() .await .map_err(|e| CoreError::IssueTracker(format!("Gitea update issue failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(CoreError::IssueTracker(format!( "Gitea update issue returned {status}: {text}" ))); } Ok(()) } async fn add_comment( &self, owner: &str, repo: &str, external_id: &str, body: &str, ) -> Result<(), CoreError> { let url = self.api_url(&format!( "/repos/{owner}/{repo}/issues/{external_id}/comments" )); let resp = self .http .post(&url) .header( "Authorization", format!("token {}", self.token.expose_secret()), ) .json(&serde_json::json!({ "body": body })) .send() .await .map_err(|e| CoreError::IssueTracker(format!("Gitea add comment failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(CoreError::IssueTracker(format!( "Gitea add comment returned {status}: {text}" ))); } Ok(()) } async fn create_pr_review( &self, owner: &str, repo: &str, pr_number: u64, body: &str, comments: Vec, ) -> Result<(), CoreError> { let url = self.api_url(&format!("/repos/{owner}/{repo}/pulls/{pr_number}/reviews")); let review_comments: Vec = comments .iter() .map(|c| { serde_json::json!({ "path": c.path, "new_position": c.line, "body": c.body, }) }) .collect(); let resp = self .http .post(&url) .header( "Authorization", format!("token {}", self.token.expose_secret()), ) .json(&serde_json::json!({ "body": body, "event": "COMMENT", "comments": review_comments, })) .send() .await .map_err(|e| CoreError::IssueTracker(format!("Gitea PR review failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); // If inline comments caused the failure, retry with just the summary body if !comments.is_empty() { tracing::warn!( "Gitea PR review with inline comments failed ({status}): {text}, retrying as plain comment" ); let fallback_url = self.api_url(&format!( "/repos/{owner}/{repo}/issues/{pr_number}/comments" )); let fallback_resp = self .http .post(&fallback_url) .header( "Authorization", format!("token {}", self.token.expose_secret()), ) .json(&serde_json::json!({ "body": body })) .send() .await .map_err(|e| { CoreError::IssueTracker(format!("Gitea PR comment fallback failed: {e}")) })?; if !fallback_resp.status().is_success() { let fb_status = fallback_resp.status(); let fb_text = fallback_resp.text().await.unwrap_or_default(); return Err(CoreError::IssueTracker(format!( "Gitea PR comment fallback returned {fb_status}: {fb_text}" ))); } return Ok(()); } return Err(CoreError::IssueTracker(format!( "Gitea PR review returned {status}: {text}" ))); } Ok(()) } async fn find_existing_issue( &self, owner: &str, repo: &str, fingerprint: &str, ) -> Result, CoreError> { let url = self.api_url(&format!( "/repos/{owner}/{repo}/issues?type=issues&state=all&q={fingerprint}" )); let resp = self .http .get(&url) .header( "Authorization", format!("token {}", self.token.expose_secret()), ) .send() .await .map_err(|e| CoreError::IssueTracker(format!("Gitea search failed: {e}")))?; let issues: Vec = resp.json().await.unwrap_or_default(); if let Some(issue) = issues.first() { Ok(Some(TrackerIssue::new( String::new(), TrackerType::Gitea, issue["number"].to_string(), issue["html_url"].as_str().unwrap_or("").to_string(), issue["title"].as_str().unwrap_or("").to_string(), ))) } else { Ok(None) } } }