aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-vcs/src/driver/bitbucket.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-23 11:38:42 +0900
committernsfisis <nsfisis@gmail.com>2026-02-23 11:38:42 +0900
commit0080efea9386d46f65d1862fcb90eb44999d9761 (patch)
treee9f7e17b3f12ff9b09b3df0848fd55e91003cd23 /crates/mozart-vcs/src/driver/bitbucket.rs
parenteb1e21c059d83f0af9786e4d3cace80afe8456a2 (diff)
downloadphp-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.rs272
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(())
+ }
+}