bitbucket.rs

  1use std::str::FromStr;
  2use std::sync::LazyLock;
  3
  4use anyhow::{Result, bail};
  5use regex::Regex;
  6use url::Url;
  7
  8use git::{
  9    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
 10    PullRequest, RemoteUrl,
 11};
 12
 13use crate::get_host_from_git_remote_url;
 14
 15fn pull_request_regex() -> &'static Regex {
 16    static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
 17        // This matches Bitbucket PR reference pattern: (pull request #xxx)
 18        Regex::new(r"\(pull request #(\d+)\)").unwrap()
 19    });
 20    &PULL_REQUEST_REGEX
 21}
 22
 23pub struct Bitbucket {
 24    name: String,
 25    base_url: Url,
 26}
 27
 28impl Bitbucket {
 29    pub fn new(name: impl Into<String>, base_url: Url) -> Self {
 30        Self {
 31            name: name.into(),
 32            base_url,
 33        }
 34    }
 35
 36    pub fn public_instance() -> Self {
 37        Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap())
 38    }
 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    }
 64}
 65
 66impl GitHostingProvider for Bitbucket {
 67    fn name(&self) -> String {
 68        self.name.clone()
 69    }
 70
 71    fn base_url(&self) -> Url {
 72        self.base_url.clone()
 73    }
 74
 75    fn supports_avatars(&self) -> bool {
 76        false
 77    }
 78
 79    fn format_line_number(&self, line: u32) -> String {
 80        if self.is_self_hosted() {
 81            return format!("{line}");
 82        }
 83        format!("lines-{line}")
 84    }
 85
 86    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        }
 90        format!("lines-{start_line}:{end_line}")
 91    }
 92
 93    fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
 94        let url = RemoteUrl::from_str(url).ok()?;
 95
 96        let host = url.host_str()?;
 97        if host != self.base_url.host_str()? {
 98            return None;
 99        }
100
101        let mut path_segments = url.path_segments()?;
102        let owner = path_segments.next()?;
103        let repo = path_segments.next()?.trim_end_matches(".git");
104
105        Some(ParsedGitRemote {
106            owner: owner.into(),
107            repo: repo.into(),
108        })
109    }
110
111    fn build_commit_permalink(
112        &self,
113        remote: &ParsedGitRemote,
114        params: BuildCommitPermalinkParams,
115    ) -> Url {
116        let BuildCommitPermalinkParams { sha } = params;
117        let ParsedGitRemote { owner, repo } = remote;
118        if self.is_self_hosted() {
119            return self
120                .base_url()
121                .join(&format!("projects/{owner}/repos/{repo}/commits/{sha}"))
122                .unwrap();
123        }
124        self.base_url()
125            .join(&format!("{owner}/{repo}/commits/{sha}"))
126            .unwrap()
127    }
128
129    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
130        let ParsedGitRemote { owner, repo } = remote;
131        let BuildPermalinkParams {
132            sha,
133            path,
134            selection,
135        } = params;
136
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
149        permalink.set_fragment(
150            selection
151                .map(|selection| self.line_fragment(&selection))
152                .as_deref(),
153        );
154        permalink
155    }
156
157    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
158        // Check first line of commit message for PR references
159        let first_line = message.lines().next()?;
160
161        // Try to match against our PR patterns
162        let capture = pull_request_regex().captures(first_line)?;
163        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
164
165        // Construct the PR URL in Bitbucket format
166        let mut url = self.base_url();
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        };
175        url.set_path(&path);
176
177        Some(PullRequest { number, url })
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use git::repository::repo_path;
184    use pretty_assertions::assert_eq;
185
186    use super::*;
187
188    #[test]
189    fn test_parse_remote_url_given_ssh_url() {
190        let parsed_remote = Bitbucket::public_instance()
191            .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
192            .unwrap();
193
194        assert_eq!(
195            parsed_remote,
196            ParsedGitRemote {
197                owner: "zed-industries".into(),
198                repo: "zed".into(),
199            }
200        );
201    }
202
203    #[test]
204    fn test_parse_remote_url_given_https_url() {
205        let parsed_remote = Bitbucket::public_instance()
206            .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
207            .unwrap();
208
209        assert_eq!(
210            parsed_remote,
211            ParsedGitRemote {
212                owner: "zed-industries".into(),
213                repo: "zed".into(),
214            }
215        );
216    }
217
218    #[test]
219    fn test_parse_remote_url_given_https_url_with_username() {
220        let parsed_remote = Bitbucket::public_instance()
221            .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
222            .unwrap();
223
224        assert_eq!(
225            parsed_remote,
226            ParsedGitRemote {
227                owner: "zed-industries".into(),
228                repo: "zed".into(),
229            }
230        );
231    }
232
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
287    #[test]
288    fn test_build_bitbucket_permalink() {
289        let permalink = Bitbucket::public_instance().build_permalink(
290            ParsedGitRemote {
291                owner: "zed-industries".into(),
292                repo: "zed".into(),
293            },
294            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
295        );
296
297        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
298        assert_eq!(permalink.to_string(), expected_url.to_string())
299    }
300
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
318    #[test]
319    fn test_build_bitbucket_permalink_with_single_line_selection() {
320        let permalink = Bitbucket::public_instance().build_permalink(
321            ParsedGitRemote {
322                owner: "zed-industries".into(),
323                repo: "zed".into(),
324            },
325            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
326        );
327
328        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
329        assert_eq!(permalink.to_string(), expected_url.to_string())
330    }
331
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
349    #[test]
350    fn test_build_bitbucket_permalink_with_multi_line_selection() {
351        let permalink = Bitbucket::public_instance().build_permalink(
352            ParsedGitRemote {
353                owner: "zed-industries".into(),
354                repo: "zed".into(),
355            },
356            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
357        );
358
359        let expected_url =
360            "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
361        assert_eq!(permalink.to_string(), expected_url.to_string())
362    }
363
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
381    #[test]
382    fn test_bitbucket_pull_requests() {
383        use indoc::indoc;
384
385        let remote = ParsedGitRemote {
386            owner: "zed-industries".into(),
387            repo: "zed".into(),
388        };
389
390        let bitbucket = Bitbucket::public_instance();
391
392        // Test message without PR reference
393        let message = "This does not contain a pull request";
394        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
395
396        // Pull request number at end of first line
397        let message = indoc! {r#"
398            Merged in feature-branch (pull request #123)
399
400            Some detailed description of the changes.
401        "#};
402
403        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
404        assert_eq!(pr.number, 123);
405        assert_eq!(
406            pr.url.as_str(),
407            "https://bitbucket.org/zed-industries/zed/pull-requests/123"
408        );
409    }
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    }
442}