git: Fix support for self-hosted Bitbucket (#42002)

ᴀᴍᴛᴏᴀᴇʀ created

Closes #41995

Release Notes:

- Fixed support for self-hosted Bitbucket

Change summary

crates/git_hosting_providers/src/git_hosting_providers.rs |   2 
crates/git_hosting_providers/src/providers/bitbucket.rs   | 205 ++++++++
2 files changed, 200 insertions(+), 7 deletions(-)

Detailed changes

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));
     }
 }
 

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<Regex> = 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<Self> {
+        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"
+        );
+    }
 }