use indexmap::IndexMap; use std::collections::BTreeMap; use anyhow::{Result, bail}; use regex::Regex; use reqwest::Client; use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; use super::git::GitDriver; use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; /// Forgejo/Gitea VCS driver using the REST API v1. /// /// Supports self-hosted instances (Codeberg, etc.). pub struct ForgejoDriver { owner: String, repo: String, host: String, scheme: String, url: String, root_identifier: Option, tags: Option>, branches: Option>, info_cache: IndexMap>, git_driver: Option>, http_client: Client, config: DriverConfig, api_failed: bool, } impl ForgejoDriver { pub fn new(url: &str, config: DriverConfig) -> Self { let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); Self { owner, repo, host, scheme, url: url.to_string(), root_identifier: None, tags: None, branches: None, info_cache: IndexMap::new(), git_driver: None, http_client: mozart_core::http::default_client(), config, api_failed: false, } } pub fn supports(url: &str, forgejo_domains: &[String]) -> bool { let url_lower = url.to_lowercase(); for domain in forgejo_domains { if url_lower.contains(domain) { return true; } } false } fn parse_url(url: &str) -> Option<(String, String, String, String)> { let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") .ok()?; let caps = re.captures(url)?; Some(( caps[2].to_string(), caps[1].to_string(), caps[3].to_string(), caps[4].to_string(), )) } fn api_url(&self, path: &str) -> String { format!( "{}://{}/api/v1/repos/{}/{}{}", self.scheme, self.host, self.owner, self.repo, path, ) } #[tracing::instrument(skip(self))] async fn api_get(&self, path: &str) -> Result { let url = self.api_url(path); let mut req = self .http_client .get(&url) .header(USER_AGENT, "mozart/0.1") .header(ACCEPT, "application/json"); if let Some(token) = &self.config.forgejo_token { req = req.header(AUTHORIZATION, format!("token {token}")); } let response = req.send().await?; tracing::debug!(status = %response.status(), %url, "Forgejo API response"); if !response.status().is_success() { bail!( "Forgejo API request to {} failed: {}", url, response.status() ); } Ok(response.json().await?) } #[tracing::instrument(skip(self))] async fn api_get_paginated(&self, path: &str) -> Result> { let mut items = Vec::new(); let mut page = 1; loop { let sep = if path.contains('?') { "&" } else { "?" }; let paged_path = format!("{path}{sep}limit=50&page={page}"); let data = self.api_get(&paged_path).await?; let batch: Vec = match data { serde_json::Value::Array(arr) => arr, _ => break, }; if batch.is_empty() { break; } items.extend(batch); page += 1; if page > 20 { break; } } Ok(items) } async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { if self.git_driver.is_none() { let git_url = format!( "{}://{}/{}/{}.git", self.scheme, self.host, self.owner, self.repo ); let mut driver = GitDriver::new(&git_url, self.config.clone()); driver.initialize().await?; self.git_driver = Some(Box::new(driver)); } Ok(self.git_driver.as_mut().unwrap()) } } impl VcsDriver for ForgejoDriver { async fn initialize(&mut self) -> Result<()> { match self.api_get("").await { Ok(data) => { let default_branch = data["default_branch"] .as_str() .unwrap_or("main") .to_string(); self.root_identifier = Some(default_branch); } Err(_) => { self.api_failed = true; let driver = self.use_git_fallback().await?; self.root_identifier = Some(driver.root_identifier().to_string()); } } Ok(()) } fn root_identifier(&self) -> &str { self.root_identifier.as_deref().unwrap_or("main") } async fn branches(&mut self) -> Result<&BTreeMap> { if self.branches.is_none() { if self.api_failed { let driver = self.use_git_fallback().await?; let branches = driver.branches().await?.clone(); self.branches = Some(branches); } else { let items = self.api_get_paginated("/branches").await?; let mut branches = BTreeMap::new(); for item in items { if let (Some(name), Some(sha)) = (item["name"].as_str(), item["commit"]["id"].as_str()) { branches.insert(name.to_string(), sha.to_string()); } } self.branches = Some(branches); } } Ok(self.branches.as_ref().unwrap()) } async fn tags(&mut self) -> Result<&BTreeMap> { if self.tags.is_none() { if self.api_failed { let driver = self.use_git_fallback().await?; let tags = driver.tags().await?.clone(); self.tags = Some(tags); } else { let items = self.api_get_paginated("/tags").await?; let mut tags = BTreeMap::new(); for item in items { if let (Some(name), Some(sha)) = ( item["name"].as_str(), item["id"].as_str().or(item["commit"]["sha"].as_str()), ) { tags.insert(name.to_string(), sha.to_string()); } } self.tags = Some(tags); } } Ok(self.tags.as_ref().unwrap()) } async fn composer_information( &mut self, identifier: &str, ) -> Result> { if let Some(cached) = self.info_cache.get(identifier) { return Ok(cached.clone()); } let content = self.file_content("composer.json", identifier).await?; let value = content.and_then(|c| serde_json::from_str(&c).ok()); self.info_cache .insert(identifier.to_string(), value.clone()); Ok(value) } async fn file_content(&self, file: &str, identifier: &str) -> Result> { if self.api_failed { return Ok(None); } let path = format!("/contents/{}?ref={}", file, identifier); match self.api_get(&path).await { Ok(data) => { if let Some(content) = data["content"].as_str() { // Forgejo returns base64-encoded content let decoded = super::github::base64_decode_content(content)?; Ok(Some(decoded)) } else { Ok(None) } } Err(_) => Ok(None), } } async fn change_date(&self, identifier: &str) -> Result> { if self.api_failed { return Ok(None); } match self.api_get(&format!("/git/commits/{identifier}")).await { Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())), Err(_) => Ok(None), } } async fn dist(&self, identifier: &str) -> Result> { Ok(Some(DistReference { dist_type: "zip".to_string(), url: format!( "{}://{}/{}/{}/archive/{}.zip", self.scheme, self.host, self.owner, self.repo, identifier, ), reference: identifier.to_string(), shasum: None, })) } fn source(&self, identifier: &str) -> SourceReference { SourceReference { source_type: "git".to_string(), url: format!( "{}://{}/{}/{}.git", self.scheme, self.host, self.owner, self.repo ), reference: identifier.to_string(), } } fn url(&self) -> &str { &self.url } async fn cleanup(&mut self) -> Result<()> { if let Some(driver) = &mut self.git_driver { driver.cleanup().await?; } Ok(()) } }