11use std:: str:: FromStr ;
22use std:: sync:: LazyLock ;
33
4+ use anyhow:: { Result , bail} ;
45use regex:: Regex ;
56use url:: Url ;
67
@@ -9,6 +10,8 @@ use git::{
910 PullRequest , RemoteUrl ,
1011} ;
1112
13+ use crate :: get_host_from_git_remote_url;
14+
1215fn pull_request_regex ( ) -> & ' static Regex {
1316 static PULL_REQUEST_REGEX : LazyLock < Regex > = LazyLock :: new ( || {
1417 // This matches Bitbucket PR reference pattern: (pull request #xxx)
@@ -33,6 +36,31 @@ impl Bitbucket {
3336 pub fn public_instance ( ) -> Self {
3437 Self :: new ( "Bitbucket" , Url :: parse ( "https://bitbucket.org" ) . unwrap ( ) )
3538 }
39+
40+ pub fn from_remote_url ( remote_url : & str ) -> Result < Self > {
41+ let host = get_host_from_git_remote_url ( remote_url) ?;
42+ if host == "bitbucket.org" {
43+ bail ! ( "the BitBucket instance is not self-hosted" ) ;
44+ }
45+
46+ // TODO: detecting self hosted instances by checking whether "bitbucket" is in the url or not
47+ // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
48+ // information.
49+ if !host. contains ( "bitbucket" ) {
50+ bail ! ( "not a BitBucket URL" ) ;
51+ }
52+
53+ Ok ( Self :: new (
54+ "BitBucket Self-Hosted" ,
55+ Url :: parse ( & format ! ( "https://{}" , host) ) ?,
56+ ) )
57+ }
58+
59+ fn is_self_hosted ( & self ) -> bool {
60+ self . base_url
61+ . host_str ( )
62+ . is_some_and ( |host| host != "bitbucket.org" )
63+ }
3664}
3765
3866impl GitHostingProvider for Bitbucket {
@@ -49,18 +77,24 @@ impl GitHostingProvider for Bitbucket {
4977 }
5078
5179 fn format_line_number ( & self , line : u32 ) -> String {
80+ if self . is_self_hosted ( ) {
81+ return format ! ( "{line}" ) ;
82+ }
5283 format ! ( "lines-{line}" )
5384 }
5485
5586 fn format_line_numbers ( & self , start_line : u32 , end_line : u32 ) -> String {
87+ if self . is_self_hosted ( ) {
88+ return format ! ( "{start_line}-{end_line}" ) ;
89+ }
5690 format ! ( "lines-{start_line}:{end_line}" )
5791 }
5892
5993 fn parse_remote_url ( & self , url : & str ) -> Option < ParsedGitRemote > {
6094 let url = RemoteUrl :: from_str ( url) . ok ( ) ?;
6195
6296 let host = url. host_str ( ) ?;
63- if host != "bitbucket.org" {
97+ if host != self . base_url . host_str ( ) ? {
6498 return None ;
6599 }
66100
@@ -81,7 +115,12 @@ impl GitHostingProvider for Bitbucket {
81115 ) -> Url {
82116 let BuildCommitPermalinkParams { sha } = params;
83117 let ParsedGitRemote { owner, repo } = remote;
84-
118+ if self . is_self_hosted ( ) {
119+ return self
120+ . base_url ( )
121+ . join ( & format ! ( "projects/{owner}/repos/{repo}/commits/{sha}" ) )
122+ . unwrap ( ) ;
123+ }
85124 self . base_url ( )
86125 . join ( & format ! ( "{owner}/{repo}/commits/{sha}" ) )
87126 . unwrap ( )
@@ -95,10 +134,18 @@ impl GitHostingProvider for Bitbucket {
95134 selection,
96135 } = params;
97136
98- let mut permalink = self
99- . base_url ( )
100- . join ( & format ! ( "{owner}/{repo}/src/{sha}/{path}" ) )
101- . unwrap ( ) ;
137+ let mut permalink = if self . is_self_hosted ( ) {
138+ self . base_url ( )
139+ . join ( & format ! (
140+ "projects/{owner}/repos/{repo}/browse/{path}?at={sha}"
141+ ) )
142+ . unwrap ( )
143+ } else {
144+ self . base_url ( )
145+ . join ( & format ! ( "{owner}/{repo}/src/{sha}/{path}" ) )
146+ . unwrap ( )
147+ } ;
148+
102149 permalink. set_fragment (
103150 selection
104151 . map ( |selection| self . line_fragment ( & selection) )
@@ -117,7 +164,14 @@ impl GitHostingProvider for Bitbucket {
117164
118165 // Construct the PR URL in Bitbucket format
119166 let mut url = self . base_url ( ) ;
120- let path = format ! ( "/{}/{}/pull-requests/{}" , remote. owner, remote. repo, number) ;
167+ let path = if self . is_self_hosted ( ) {
168+ format ! (
169+ "/projects/{}/repos/{}/pull-requests/{}" ,
170+ remote. owner, remote. repo, number
171+ )
172+ } else {
173+ format ! ( "/{}/{}/pull-requests/{}" , remote. owner, remote. repo, number)
174+ } ;
121175 url. set_path ( & path) ;
122176
123177 Some ( PullRequest { number, url } )
@@ -176,6 +230,60 @@ mod tests {
176230 ) ;
177231 }
178232
233+ #[ test]
234+ fn test_parse_remote_url_given_self_hosted_ssh_url ( ) {
235+ let remote_url = "git@bitbucket.company.com:zed-industries/zed.git" ;
236+
237+ let parsed_remote = Bitbucket :: from_remote_url ( remote_url)
238+ . unwrap ( )
239+ . parse_remote_url ( remote_url)
240+ . unwrap ( ) ;
241+
242+ assert_eq ! (
243+ parsed_remote,
244+ ParsedGitRemote {
245+ owner: "zed-industries" . into( ) ,
246+ repo: "zed" . into( ) ,
247+ }
248+ ) ;
249+ }
250+
251+ #[ test]
252+ fn test_parse_remote_url_given_self_hosted_https_url ( ) {
253+ let remote_url = "https://bitbucket.company.com/zed-industries/zed.git" ;
254+
255+ let parsed_remote = Bitbucket :: from_remote_url ( remote_url)
256+ . unwrap ( )
257+ . parse_remote_url ( remote_url)
258+ . unwrap ( ) ;
259+
260+ assert_eq ! (
261+ parsed_remote,
262+ ParsedGitRemote {
263+ owner: "zed-industries" . into( ) ,
264+ repo: "zed" . into( ) ,
265+ }
266+ ) ;
267+ }
268+
269+ #[ test]
270+ fn test_parse_remote_url_given_self_hosted_https_url_with_username ( ) {
271+ let remote_url = "https://thorstenballzed@bitbucket.company.com/zed-industries/zed.git" ;
272+
273+ let parsed_remote = Bitbucket :: from_remote_url ( remote_url)
274+ . unwrap ( )
275+ . parse_remote_url ( remote_url)
276+ . unwrap ( ) ;
277+
278+ assert_eq ! (
279+ parsed_remote,
280+ ParsedGitRemote {
281+ owner: "zed-industries" . into( ) ,
282+ repo: "zed" . into( ) ,
283+ }
284+ ) ;
285+ }
286+
179287 #[ test]
180288 fn test_build_bitbucket_permalink ( ) {
181289 let permalink = Bitbucket :: public_instance ( ) . build_permalink (
@@ -190,6 +298,23 @@ mod tests {
190298 assert_eq ! ( permalink. to_string( ) , expected_url. to_string( ) )
191299 }
192300
301+ #[ test]
302+ fn test_build_bitbucket_self_hosted_permalink ( ) {
303+ let permalink =
304+ Bitbucket :: from_remote_url ( "git@bitbucket.company.com:zed-industries/zed.git" )
305+ . unwrap ( )
306+ . build_permalink (
307+ ParsedGitRemote {
308+ owner : "zed-industries" . into ( ) ,
309+ repo : "zed" . into ( ) ,
310+ } ,
311+ BuildPermalinkParams :: new ( "f00b4r" , & repo_path ( "main.rs" ) , None ) ,
312+ ) ;
313+
314+ let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r" ;
315+ assert_eq ! ( permalink. to_string( ) , expected_url. to_string( ) )
316+ }
317+
193318 #[ test]
194319 fn test_build_bitbucket_permalink_with_single_line_selection ( ) {
195320 let permalink = Bitbucket :: public_instance ( ) . build_permalink (
@@ -204,6 +329,23 @@ mod tests {
204329 assert_eq ! ( permalink. to_string( ) , expected_url. to_string( ) )
205330 }
206331
332+ #[ test]
333+ fn test_build_bitbucket_self_hosted_permalink_with_single_line_selection ( ) {
334+ let permalink =
335+ Bitbucket :: from_remote_url ( "https://bitbucket.company.com/zed-industries/zed.git" )
336+ . unwrap ( )
337+ . build_permalink (
338+ ParsedGitRemote {
339+ owner : "zed-industries" . into ( ) ,
340+ repo : "zed" . into ( ) ,
341+ } ,
342+ BuildPermalinkParams :: new ( "f00b4r" , & repo_path ( "main.rs" ) , Some ( 6 ..6 ) ) ,
343+ ) ;
344+
345+ let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#7" ;
346+ assert_eq ! ( permalink. to_string( ) , expected_url. to_string( ) )
347+ }
348+
207349 #[ test]
208350 fn test_build_bitbucket_permalink_with_multi_line_selection ( ) {
209351 let permalink = Bitbucket :: public_instance ( ) . build_permalink (
@@ -219,6 +361,23 @@ mod tests {
219361 assert_eq ! ( permalink. to_string( ) , expected_url. to_string( ) )
220362 }
221363
364+ #[ test]
365+ fn test_build_bitbucket_self_hosted_permalink_with_multi_line_selection ( ) {
366+ let permalink =
367+ Bitbucket :: from_remote_url ( "git@bitbucket.company.com:zed-industries/zed.git" )
368+ . unwrap ( )
369+ . build_permalink (
370+ ParsedGitRemote {
371+ owner : "zed-industries" . into ( ) ,
372+ repo : "zed" . into ( ) ,
373+ } ,
374+ BuildPermalinkParams :: new ( "f00b4r" , & repo_path ( "main.rs" ) , Some ( 23 ..47 ) ) ,
375+ ) ;
376+
377+ let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#24-48" ;
378+ assert_eq ! ( permalink. to_string( ) , expected_url. to_string( ) )
379+ }
380+
222381 #[ test]
223382 fn test_bitbucket_pull_requests ( ) {
224383 use indoc:: indoc;
@@ -248,4 +407,36 @@ mod tests {
248407 "https://bitbucket.org/zed-industries/zed/pull-requests/123"
249408 ) ;
250409 }
410+
411+ #[ test]
412+ fn test_bitbucket_self_hosted_pull_requests ( ) {
413+ use indoc:: indoc;
414+
415+ let remote = ParsedGitRemote {
416+ owner : "zed-industries" . into ( ) ,
417+ repo : "zed" . into ( ) ,
418+ } ;
419+
420+ let bitbucket =
421+ Bitbucket :: from_remote_url ( "https://bitbucket.company.com/zed-industries/zed.git" )
422+ . unwrap ( ) ;
423+
424+ // Test message without PR reference
425+ let message = "This does not contain a pull request" ;
426+ assert ! ( bitbucket. extract_pull_request( & remote, message) . is_none( ) ) ;
427+
428+ // Pull request number at end of first line
429+ let message = indoc ! { r#"
430+ Merged in feature-branch (pull request #123)
431+
432+ Some detailed description of the changes.
433+ "# } ;
434+
435+ let pr = bitbucket. extract_pull_request ( & remote, message) . unwrap ( ) ;
436+ assert_eq ! ( pr. number, 123 ) ;
437+ assert_eq ! (
438+ pr. url. as_str( ) ,
439+ "https://bitbucket.company.com/projects/zed-industries/repos/zed/pull-requests/123"
440+ ) ;
441+ }
251442}
0 commit comments