diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-23 11:38:42 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-23 11:38:42 +0900 |
| commit | 0080efea9386d46f65d1862fcb90eb44999d9761 (patch) | |
| tree | e9f7e17b3f12ff9b09b3df0848fd55e91003cd23 /crates/mozart-vcs/src/driver/bitbucket.rs | |
| parent | eb1e21c059d83f0af9786e4d3cace80afe8456a2 (diff) | |
| download | php-mozart-0080efea9386d46f65d1862fcb90eb44999d9761.tar.gz php-mozart-0080efea9386d46f65d1862fcb90eb44999d9761.tar.zst php-mozart-0080efea9386d46f65d1862fcb90eb44999d9761.zip | |
feat(vcs): add mozart-vcs crate for VCS repository support
Implement VCS driver/downloader infrastructure mirroring Composer's VCS
subsystem. Includes drivers for GitHub, GitLab, Bitbucket, Forgejo, Git,
Hg, and SVN with API-based metadata resolution, plus source downloaders
for Git/Hg/SVN. Integrates into mozart-registry via vcs_bridge module to
scan VCS repositories and feed discovered packages into the SAT resolver.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-vcs/src/driver/bitbucket.rs')
| -rw-r--r-- | crates/mozart-vcs/src/driver/bitbucket.rs | 272 |
1 files changed, 272 insertions, 0 deletions
diff --git a/crates/mozart-vcs/src/driver/bitbucket.rs b/crates/mozart-vcs/src/driver/bitbucket.rs new file mode 100644 index 0000000..9a0fc15 --- /dev/null +++ b/crates/mozart-vcs/src/driver/bitbucket.rs @@ -0,0 +1,272 @@ +use std::collections::{BTreeMap, HashMap}; + +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}; + +/// Bitbucket VCS driver using the REST API 2.0. +pub struct BitbucketDriver { + owner: String, + repo: String, + url: String, + root_identifier: Option<String>, + tags: Option<BTreeMap<String, String>>, + branches: Option<BTreeMap<String, String>>, + info_cache: HashMap<String, Option<serde_json::Value>>, + git_driver: Option<Box<GitDriver>>, + http_client: Client, + config: DriverConfig, + api_failed: bool, + vcs_type: String, // "git" or "hg" +} + +impl BitbucketDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + url: url.to_string(), + root_identifier: None, + tags: None, + branches: None, + info_cache: HashMap::new(), + git_driver: None, + http_client: Client::new(), + config, + api_failed: false, + vcs_type: "git".to_string(), + } + } + + pub fn supports(url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains("bitbucket.org") + } + + fn parse_url(url: &str) -> Option<(String, String)> { + let re = + Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; + let caps = re.captures(url)?; + Some((caps[1].to_string(), caps[2].to_string())) + } + + fn api_url(&self, path: &str) -> String { + format!( + "https://api.bitbucket.org/2.0/repositories/{}/{}{}", + self.owner, self.repo, path, + ) + } + + fn api_get(&self, path: &str) -> Result<serde_json::Value> { + let handle = tokio::runtime::Handle::current(); + 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((key, secret)) = &self.config.bitbucket_oauth { + let credentials = format!("{key}:{secret}"); + req = req.header(AUTHORIZATION, format!("Basic {credentials}")); + } + + let response = handle.block_on(req.send())?; + if !response.status().is_success() { + bail!( + "Bitbucket API request to {} failed: {}", + url, + response.status() + ); + } + Ok(handle.block_on(response.json())?) + } + + fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> { + let handle = tokio::runtime::Handle::current(); + let mut items = Vec::new(); + let mut next_url = Some(self.api_url(path)); + let mut pages = 0; + + while let Some(url) = next_url { + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + if let Some((key, secret)) = &self.config.bitbucket_oauth { + req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); + } + let response = handle.block_on(req.send())?; + if !response.status().is_success() { + break; + } + let data: serde_json::Value = handle.block_on(response.json())?; + if let Some(values) = data["values"].as_array() { + items.extend(values.iter().cloned()); + } + next_url = data["next"].as_str().map(|s: &str| s.to_string()); + pages += 1; + if pages > 10 { + break; + } + } + Ok(items) + } + + fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize()?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for BitbucketDriver { + fn initialize(&mut self) -> Result<()> { + match self.api_get("") { + Ok(data) => { + if let Some(scm) = data["scm"].as_str() { + self.vcs_type = scm.to_string(); + } + let default_branch = data["mainbranch"]["name"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback()?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + fn branches(&mut self) -> Result<&BTreeMap<String, String>> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let branches = driver.branches()?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/refs/branches?pagelen=100")?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["target"]["hash"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + fn tags(&mut self) -> Result<&BTreeMap<String, String>> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let tags = driver.tags()?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/refs/tags?pagelen=100")?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["target"]["hash"].as_str()) + { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier)?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> { + if self.api_failed { + return Ok(None); + } + let handle = tokio::runtime::Handle::current(); + let url = self.api_url(&format!("/src/{identifier}/{file}")); + let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); + if let Some((key, secret)) = &self.config.bitbucket_oauth { + req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); + } + let response = handle.block_on(req.send())?; + if response.status().is_success() { + Ok(Some(handle.block_on(response.text())?)) + } else { + Ok(None) + } + } + + fn change_date(&self, identifier: &str) -> Result<Option<String>> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/commit/{identifier}")) { + Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + fn dist(&self, identifier: &str) -> Result<Option<DistReference>> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "https://bitbucket.org/{}/{}/get/{}.zip", + self.owner, self.repo, identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: self.vcs_type.clone(), + url: format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup()?; + } + Ok(()) + } +} |
