From 35c58151eb0a1cbb9ebcc9be605b00b9fedfd5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Mon, 10 Nov 2025 10:37:22 +0800 Subject: [PATCH] git: Fix support for self-hosted Bitbucket (#42002) Closes #41995 Release Notes: - Fixed support for self-hosted Bitbucket --- .../src/git_hosting_providers.rs | 2 + .../src/providers/bitbucket.rs | 205 +++++++++++++++++- 2 files changed, 200 insertions(+), 7 deletions(-) diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 307db49a8ebd33228e6c1bccfbffc065b5f563b3..6940ea382a1a21dbb3e97b55d74ee2489a1691ba 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -49,6 +49,8 @@ pub fn register_additional_providers( provider_registry.register_hosting_provider(Arc::new(forgejo_self_hosted)); } else if let Ok(gitea_self_hosted) = Gitea::from_remote_url(&origin_url) { provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted)); + } else if let Ok(bitbucket_self_hosted) = Bitbucket::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(bitbucket_self_hosted)); } } diff --git a/crates/git_hosting_providers/src/providers/bitbucket.rs b/crates/git_hosting_providers/src/providers/bitbucket.rs index a6bb83b0f9d6025301db309c4d64ea39ade42076..0c30a13758a8339087ebb146f0029baee0d3ea7e 100644 --- a/crates/git_hosting_providers/src/providers/bitbucket.rs +++ b/crates/git_hosting_providers/src/providers/bitbucket.rs @@ -1,6 +1,7 @@ use std::str::FromStr; use std::sync::LazyLock; +use anyhow::{Result, bail}; use regex::Regex; use url::Url; @@ -9,6 +10,8 @@ use git::{ PullRequest, RemoteUrl, }; +use crate::get_host_from_git_remote_url; + fn pull_request_regex() -> &'static Regex { static PULL_REQUEST_REGEX: LazyLock = LazyLock::new(|| { // This matches Bitbucket PR reference pattern: (pull request #xxx) @@ -33,6 +36,31 @@ impl Bitbucket { pub fn public_instance() -> Self { Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap()) } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "bitbucket.org" { + bail!("the BitBucket instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "bitbucket" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("bitbucket") { + bail!("not a BitBucket URL"); + } + + Ok(Self::new( + "BitBucket Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } + + fn is_self_hosted(&self) -> bool { + self.base_url + .host_str() + .is_some_and(|host| host != "bitbucket.org") + } } impl GitHostingProvider for Bitbucket { @@ -49,10 +77,16 @@ impl GitHostingProvider for Bitbucket { } fn format_line_number(&self, line: u32) -> String { + if self.is_self_hosted() { + return format!("{line}"); + } format!("lines-{line}") } fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String { + if self.is_self_hosted() { + return format!("{start_line}-{end_line}"); + } format!("lines-{start_line}:{end_line}") } @@ -60,7 +94,7 @@ impl GitHostingProvider for Bitbucket { let url = RemoteUrl::from_str(url).ok()?; let host = url.host_str()?; - if host != "bitbucket.org" { + if host != self.base_url.host_str()? { return None; } @@ -81,7 +115,12 @@ impl GitHostingProvider for Bitbucket { ) -> Url { let BuildCommitPermalinkParams { sha } = params; let ParsedGitRemote { owner, repo } = remote; - + if self.is_self_hosted() { + return self + .base_url() + .join(&format!("projects/{owner}/repos/{repo}/commits/{sha}")) + .unwrap(); + } self.base_url() .join(&format!("{owner}/{repo}/commits/{sha}")) .unwrap() @@ -95,10 +134,18 @@ impl GitHostingProvider for Bitbucket { selection, } = params; - let mut permalink = self - .base_url() - .join(&format!("{owner}/{repo}/src/{sha}/{path}")) - .unwrap(); + let mut permalink = if self.is_self_hosted() { + self.base_url() + .join(&format!( + "projects/{owner}/repos/{repo}/browse/{path}?at={sha}" + )) + .unwrap() + } else { + self.base_url() + .join(&format!("{owner}/{repo}/src/{sha}/{path}")) + .unwrap() + }; + permalink.set_fragment( selection .map(|selection| self.line_fragment(&selection)) @@ -117,7 +164,14 @@ impl GitHostingProvider for Bitbucket { // Construct the PR URL in Bitbucket format let mut url = self.base_url(); - let path = format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number); + let path = if self.is_self_hosted() { + format!( + "/projects/{}/repos/{}/pull-requests/{}", + remote.owner, remote.repo, number + ) + } else { + format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number) + }; url.set_path(&path); Some(PullRequest { number, url }) @@ -176,6 +230,60 @@ mod tests { ); } + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@bitbucket.company.com:zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://bitbucket.company.com/zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url_with_username() { + let remote_url = "https://thorstenballzed@bitbucket.company.com/zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + #[test] fn test_build_bitbucket_permalink() { let permalink = Bitbucket::public_instance().build_permalink( @@ -190,6 +298,23 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_bitbucket_self_hosted_permalink() { + let permalink = + Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None), + ); + + let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_build_bitbucket_permalink_with_single_line_selection() { let permalink = Bitbucket::public_instance().build_permalink( @@ -204,6 +329,23 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_bitbucket_self_hosted_permalink_with_single_line_selection() { + let permalink = + Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)), + ); + + let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_build_bitbucket_permalink_with_multi_line_selection() { let permalink = Bitbucket::public_instance().build_permalink( @@ -219,6 +361,23 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_bitbucket_self_hosted_permalink_with_multi_line_selection() { + let permalink = + Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)), + ); + + let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#24-48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_bitbucket_pull_requests() { use indoc::indoc; @@ -248,4 +407,36 @@ mod tests { "https://bitbucket.org/zed-industries/zed/pull-requests/123" ); } + + #[test] + fn test_bitbucket_self_hosted_pull_requests() { + use indoc::indoc; + + let remote = ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }; + + let bitbucket = + Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git") + .unwrap(); + + // Test message without PR reference + let message = "This does not contain a pull request"; + assert!(bitbucket.extract_pull_request(&remote, message).is_none()); + + // Pull request number at end of first line + let message = indoc! {r#" + Merged in feature-branch (pull request #123) + + Some detailed description of the changes. + "#}; + + let pr = bitbucket.extract_pull_request(&remote, message).unwrap(); + assert_eq!(pr.number, 123); + assert_eq!( + pr.url.as_str(), + "https://bitbucket.company.com/projects/zed-industries/repos/zed/pull-requests/123" + ); + } }