bitbucket.rs

  1use std::sync::LazyLock;
  2use std::{str::FromStr, sync::Arc};
  3
  4use anyhow::{Context as _, Result, bail};
  5use async_trait::async_trait;
  6use futures::AsyncReadExt;
  7use gpui::SharedString;
  8use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
  9use itertools::Itertools as _;
 10use regex::Regex;
 11use serde::Deserialize;
 12use url::Url;
 13
 14use git::{
 15    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
 16    PullRequest, RemoteUrl,
 17};
 18
 19use crate::get_host_from_git_remote_url;
 20
 21fn pull_request_regex() -> &'static Regex {
 22    static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
 23        // This matches Bitbucket PR reference pattern: (pull request #xxx)
 24        Regex::new(r"\(pull request #(\d+)\)").unwrap()
 25    });
 26    &PULL_REQUEST_REGEX
 27}
 28
 29#[derive(Debug, Deserialize)]
 30struct CommitDetails {
 31    author: Author,
 32}
 33
 34#[derive(Debug, Deserialize)]
 35struct Author {
 36    user: Account,
 37}
 38
 39#[derive(Debug, Deserialize)]
 40struct Account {
 41    links: AccountLinks,
 42}
 43
 44#[derive(Debug, Deserialize)]
 45struct AccountLinks {
 46    avatar: Option<Link>,
 47}
 48
 49#[derive(Debug, Deserialize)]
 50struct Link {
 51    href: String,
 52}
 53
 54#[derive(Debug, Deserialize)]
 55struct CommitDetailsSelfHosted {
 56    author: AuthorSelfHosted,
 57}
 58
 59#[derive(Debug, Deserialize)]
 60#[serde(rename_all = "camelCase")]
 61struct AuthorSelfHosted {
 62    avatar_url: Option<String>,
 63}
 64
 65pub struct Bitbucket {
 66    name: String,
 67    base_url: Url,
 68}
 69
 70impl Bitbucket {
 71    pub fn new(name: impl Into<String>, base_url: Url) -> Self {
 72        Self {
 73            name: name.into(),
 74            base_url,
 75        }
 76    }
 77
 78    pub fn public_instance() -> Self {
 79        Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap())
 80    }
 81
 82    pub fn from_remote_url(remote_url: &str) -> Result<Self> {
 83        let host = get_host_from_git_remote_url(remote_url)?;
 84        if host == "bitbucket.org" {
 85            bail!("the BitBucket instance is not self-hosted");
 86        }
 87
 88        // TODO: detecting self hosted instances by checking whether "bitbucket" is in the url or not
 89        // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
 90        // information.
 91        if !host.contains("bitbucket") {
 92            bail!("not a BitBucket URL");
 93        }
 94
 95        Ok(Self::new(
 96            "BitBucket Self-Hosted",
 97            Url::parse(&format!("https://{}", host))?,
 98        ))
 99    }
100
101    fn is_self_hosted(&self) -> bool {
102        self.base_url
103            .host_str()
104            .is_some_and(|host| host != "bitbucket.org")
105    }
106
107    async fn fetch_bitbucket_commit_author(
108        &self,
109        repo_owner: &str,
110        repo: &str,
111        commit: &str,
112        client: &Arc<dyn HttpClient>,
113    ) -> Result<Option<String>> {
114        let Some(host) = self.base_url.host_str() else {
115            bail!("failed to get host from bitbucket base url");
116        };
117        let is_self_hosted = self.is_self_hosted();
118        let url = if is_self_hosted {
119            format!(
120                "https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128"
121            )
122        } else {
123            format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}")
124        };
125
126        let request = Request::get(&url)
127            .header("Content-Type", "application/json")
128            .follow_redirects(http_client::RedirectPolicy::FollowAll);
129
130        let mut response = client
131            .send(request.body(AsyncBody::default())?)
132            .await
133            .with_context(|| format!("error fetching BitBucket commit details at {:?}", url))?;
134
135        let mut body = Vec::new();
136        response.body_mut().read_to_end(&mut body).await?;
137
138        if response.status().is_client_error() {
139            let text = String::from_utf8_lossy(body.as_slice());
140            bail!(
141                "status error {}, response: {text:?}",
142                response.status().as_u16()
143            );
144        }
145
146        let body_str = std::str::from_utf8(&body)?;
147
148        if is_self_hosted {
149            serde_json::from_str::<CommitDetailsSelfHosted>(body_str)
150                .map(|commit| commit.author.avatar_url)
151        } else {
152            serde_json::from_str::<CommitDetails>(body_str)
153                .map(|commit| commit.author.user.links.avatar.map(|link| link.href))
154        }
155        .context("failed to deserialize BitBucket commit details")
156    }
157}
158
159#[async_trait]
160impl GitHostingProvider for Bitbucket {
161    fn name(&self) -> String {
162        self.name.clone()
163    }
164
165    fn base_url(&self) -> Url {
166        self.base_url.clone()
167    }
168
169    fn supports_avatars(&self) -> bool {
170        true
171    }
172
173    fn format_line_number(&self, line: u32) -> String {
174        if self.is_self_hosted() {
175            return format!("{line}");
176        }
177        format!("lines-{line}")
178    }
179
180    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
181        if self.is_self_hosted() {
182            return format!("{start_line}-{end_line}");
183        }
184        format!("lines-{start_line}:{end_line}")
185    }
186
187    fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
188        let url = RemoteUrl::from_str(url).ok()?;
189
190        let host = url.host_str()?;
191        if host != self.base_url.host_str()? {
192            return None;
193        }
194
195        let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
196        let repo = path_segments.pop()?.trim_end_matches(".git");
197        let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1
198        {
199            // Skip the "scm" segment if it's not the only segment
200            // https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74
201            path_segments.into_iter().skip(1).join("/")
202        } else {
203            path_segments.into_iter().join("/")
204        };
205
206        Some(ParsedGitRemote {
207            owner: owner.into(),
208            repo: repo.into(),
209        })
210    }
211
212    fn build_commit_permalink(
213        &self,
214        remote: &ParsedGitRemote,
215        params: BuildCommitPermalinkParams,
216    ) -> Url {
217        let BuildCommitPermalinkParams { sha } = params;
218        let ParsedGitRemote { owner, repo } = remote;
219        if self.is_self_hosted() {
220            return self
221                .base_url()
222                .join(&format!("projects/{owner}/repos/{repo}/commits/{sha}"))
223                .unwrap();
224        }
225        self.base_url()
226            .join(&format!("{owner}/{repo}/commits/{sha}"))
227            .unwrap()
228    }
229
230    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
231        let ParsedGitRemote { owner, repo } = remote;
232        let BuildPermalinkParams {
233            sha,
234            path,
235            selection,
236        } = params;
237
238        let mut permalink = if self.is_self_hosted() {
239            self.base_url()
240                .join(&format!(
241                    "projects/{owner}/repos/{repo}/browse/{path}?at={sha}"
242                ))
243                .unwrap()
244        } else {
245            self.base_url()
246                .join(&format!("{owner}/{repo}/src/{sha}/{path}"))
247                .unwrap()
248        };
249
250        permalink.set_fragment(
251            selection
252                .map(|selection| self.line_fragment(&selection))
253                .as_deref(),
254        );
255        permalink
256    }
257
258    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
259        // Check first line of commit message for PR references
260        let first_line = message.lines().next()?;
261
262        // Try to match against our PR patterns
263        let capture = pull_request_regex().captures(first_line)?;
264        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
265
266        // Construct the PR URL in Bitbucket format
267        let mut url = self.base_url();
268        let path = if self.is_self_hosted() {
269            format!(
270                "/projects/{}/repos/{}/pull-requests/{}",
271                remote.owner, remote.repo, number
272            )
273        } else {
274            format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number)
275        };
276        url.set_path(&path);
277
278        Some(PullRequest { number, url })
279    }
280
281    async fn commit_author_avatar_url(
282        &self,
283        repo_owner: &str,
284        repo: &str,
285        commit: SharedString,
286        _author_email: Option<SharedString>,
287        http_client: Arc<dyn HttpClient>,
288    ) -> Result<Option<Url>> {
289        let commit = commit.to_string();
290        let avatar_url = self
291            .fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client)
292            .await?
293            .map(|avatar_url| Url::parse(&avatar_url))
294            .transpose()?;
295        Ok(avatar_url)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use git::repository::repo_path;
302    use pretty_assertions::assert_eq;
303
304    use super::*;
305
306    #[test]
307    fn test_parse_remote_url_given_ssh_url() {
308        let parsed_remote = Bitbucket::public_instance()
309            .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
310            .unwrap();
311
312        assert_eq!(
313            parsed_remote,
314            ParsedGitRemote {
315                owner: "zed-industries".into(),
316                repo: "zed".into(),
317            }
318        );
319    }
320
321    #[test]
322    fn test_parse_remote_url_given_https_url() {
323        let parsed_remote = Bitbucket::public_instance()
324            .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
325            .unwrap();
326
327        assert_eq!(
328            parsed_remote,
329            ParsedGitRemote {
330                owner: "zed-industries".into(),
331                repo: "zed".into(),
332            }
333        );
334    }
335
336    #[test]
337    fn test_parse_remote_url_given_https_url_with_username() {
338        let parsed_remote = Bitbucket::public_instance()
339            .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
340            .unwrap();
341
342        assert_eq!(
343            parsed_remote,
344            ParsedGitRemote {
345                owner: "zed-industries".into(),
346                repo: "zed".into(),
347            }
348        );
349    }
350
351    #[test]
352    fn test_parse_remote_url_given_self_hosted_ssh_url() {
353        let remote_url = "git@bitbucket.company.com:zed-industries/zed.git";
354
355        let parsed_remote = Bitbucket::from_remote_url(remote_url)
356            .unwrap()
357            .parse_remote_url(remote_url)
358            .unwrap();
359
360        assert_eq!(
361            parsed_remote,
362            ParsedGitRemote {
363                owner: "zed-industries".into(),
364                repo: "zed".into(),
365            }
366        );
367    }
368
369    #[test]
370    fn test_parse_remote_url_given_self_hosted_https_url() {
371        let remote_url = "https://bitbucket.company.com/zed-industries/zed.git";
372
373        let parsed_remote = Bitbucket::from_remote_url(remote_url)
374            .unwrap()
375            .parse_remote_url(remote_url)
376            .unwrap();
377
378        assert_eq!(
379            parsed_remote,
380            ParsedGitRemote {
381                owner: "zed-industries".into(),
382                repo: "zed".into(),
383            }
384        );
385
386        // Test with "scm" in the path
387        let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git";
388
389        let parsed_remote = Bitbucket::from_remote_url(remote_url)
390            .unwrap()
391            .parse_remote_url(remote_url)
392            .unwrap();
393
394        assert_eq!(
395            parsed_remote,
396            ParsedGitRemote {
397                owner: "zed-industries".into(),
398                repo: "zed".into(),
399            }
400        );
401
402        // Test with only "scm" as owner
403        let remote_url = "https://bitbucket.company.com/scm/zed.git";
404
405        let parsed_remote = Bitbucket::from_remote_url(remote_url)
406            .unwrap()
407            .parse_remote_url(remote_url)
408            .unwrap();
409
410        assert_eq!(
411            parsed_remote,
412            ParsedGitRemote {
413                owner: "scm".into(),
414                repo: "zed".into(),
415            }
416        );
417    }
418
419    #[test]
420    fn test_parse_remote_url_given_self_hosted_https_url_with_username() {
421        let remote_url = "https://thorstenballzed@bitbucket.company.com/zed-industries/zed.git";
422
423        let parsed_remote = Bitbucket::from_remote_url(remote_url)
424            .unwrap()
425            .parse_remote_url(remote_url)
426            .unwrap();
427
428        assert_eq!(
429            parsed_remote,
430            ParsedGitRemote {
431                owner: "zed-industries".into(),
432                repo: "zed".into(),
433            }
434        );
435    }
436
437    #[test]
438    fn test_build_bitbucket_permalink() {
439        let permalink = Bitbucket::public_instance().build_permalink(
440            ParsedGitRemote {
441                owner: "zed-industries".into(),
442                repo: "zed".into(),
443            },
444            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
445        );
446
447        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
448        assert_eq!(permalink.to_string(), expected_url.to_string())
449    }
450
451    #[test]
452    fn test_build_bitbucket_self_hosted_permalink() {
453        let permalink =
454            Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
455                .unwrap()
456                .build_permalink(
457                    ParsedGitRemote {
458                        owner: "zed-industries".into(),
459                        repo: "zed".into(),
460                    },
461                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
462                );
463
464        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r";
465        assert_eq!(permalink.to_string(), expected_url.to_string())
466    }
467
468    #[test]
469    fn test_build_bitbucket_permalink_with_single_line_selection() {
470        let permalink = Bitbucket::public_instance().build_permalink(
471            ParsedGitRemote {
472                owner: "zed-industries".into(),
473                repo: "zed".into(),
474            },
475            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
476        );
477
478        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
479        assert_eq!(permalink.to_string(), expected_url.to_string())
480    }
481
482    #[test]
483    fn test_build_bitbucket_self_hosted_permalink_with_single_line_selection() {
484        let permalink =
485            Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
486                .unwrap()
487                .build_permalink(
488                    ParsedGitRemote {
489                        owner: "zed-industries".into(),
490                        repo: "zed".into(),
491                    },
492                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
493                );
494
495        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#7";
496        assert_eq!(permalink.to_string(), expected_url.to_string())
497    }
498
499    #[test]
500    fn test_build_bitbucket_permalink_with_multi_line_selection() {
501        let permalink = Bitbucket::public_instance().build_permalink(
502            ParsedGitRemote {
503                owner: "zed-industries".into(),
504                repo: "zed".into(),
505            },
506            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
507        );
508
509        let expected_url =
510            "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
511        assert_eq!(permalink.to_string(), expected_url.to_string())
512    }
513
514    #[test]
515    fn test_build_bitbucket_self_hosted_permalink_with_multi_line_selection() {
516        let permalink =
517            Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
518                .unwrap()
519                .build_permalink(
520                    ParsedGitRemote {
521                        owner: "zed-industries".into(),
522                        repo: "zed".into(),
523                    },
524                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
525                );
526
527        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#24-48";
528        assert_eq!(permalink.to_string(), expected_url.to_string())
529    }
530
531    #[test]
532    fn test_bitbucket_pull_requests() {
533        use indoc::indoc;
534
535        let remote = ParsedGitRemote {
536            owner: "zed-industries".into(),
537            repo: "zed".into(),
538        };
539
540        let bitbucket = Bitbucket::public_instance();
541
542        // Test message without PR reference
543        let message = "This does not contain a pull request";
544        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
545
546        // Pull request number at end of first line
547        let message = indoc! {r#"
548            Merged in feature-branch (pull request #123)
549
550            Some detailed description of the changes.
551        "#};
552
553        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
554        assert_eq!(pr.number, 123);
555        assert_eq!(
556            pr.url.as_str(),
557            "https://bitbucket.org/zed-industries/zed/pull-requests/123"
558        );
559    }
560
561    #[test]
562    fn test_bitbucket_self_hosted_pull_requests() {
563        use indoc::indoc;
564
565        let remote = ParsedGitRemote {
566            owner: "zed-industries".into(),
567            repo: "zed".into(),
568        };
569
570        let bitbucket =
571            Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
572                .unwrap();
573
574        // Test message without PR reference
575        let message = "This does not contain a pull request";
576        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
577
578        // Pull request number at end of first line
579        let message = indoc! {r#"
580            Merged in feature-branch (pull request #123)
581
582            Some detailed description of the changes.
583        "#};
584
585        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
586        assert_eq!(pr.number, 123);
587        assert_eq!(
588            pr.url.as_str(),
589            "https://bitbucket.company.com/projects/zed-industries/repos/zed/pull-requests/123"
590        );
591    }
592}