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        http_client: Arc<dyn HttpClient>,
287    ) -> Result<Option<Url>> {
288        let commit = commit.to_string();
289        let avatar_url = self
290            .fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client)
291            .await?
292            .map(|avatar_url| Url::parse(&avatar_url))
293            .transpose()?;
294        Ok(avatar_url)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use git::repository::repo_path;
301    use pretty_assertions::assert_eq;
302
303    use super::*;
304
305    #[test]
306    fn test_parse_remote_url_given_ssh_url() {
307        let parsed_remote = Bitbucket::public_instance()
308            .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
309            .unwrap();
310
311        assert_eq!(
312            parsed_remote,
313            ParsedGitRemote {
314                owner: "zed-industries".into(),
315                repo: "zed".into(),
316            }
317        );
318    }
319
320    #[test]
321    fn test_parse_remote_url_given_https_url() {
322        let parsed_remote = Bitbucket::public_instance()
323            .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
324            .unwrap();
325
326        assert_eq!(
327            parsed_remote,
328            ParsedGitRemote {
329                owner: "zed-industries".into(),
330                repo: "zed".into(),
331            }
332        );
333    }
334
335    #[test]
336    fn test_parse_remote_url_given_https_url_with_username() {
337        let parsed_remote = Bitbucket::public_instance()
338            .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
339            .unwrap();
340
341        assert_eq!(
342            parsed_remote,
343            ParsedGitRemote {
344                owner: "zed-industries".into(),
345                repo: "zed".into(),
346            }
347        );
348    }
349
350    #[test]
351    fn test_parse_remote_url_given_self_hosted_ssh_url() {
352        let remote_url = "git@bitbucket.company.com:zed-industries/zed.git";
353
354        let parsed_remote = Bitbucket::from_remote_url(remote_url)
355            .unwrap()
356            .parse_remote_url(remote_url)
357            .unwrap();
358
359        assert_eq!(
360            parsed_remote,
361            ParsedGitRemote {
362                owner: "zed-industries".into(),
363                repo: "zed".into(),
364            }
365        );
366    }
367
368    #[test]
369    fn test_parse_remote_url_given_self_hosted_https_url() {
370        let remote_url = "https://bitbucket.company.com/zed-industries/zed.git";
371
372        let parsed_remote = Bitbucket::from_remote_url(remote_url)
373            .unwrap()
374            .parse_remote_url(remote_url)
375            .unwrap();
376
377        assert_eq!(
378            parsed_remote,
379            ParsedGitRemote {
380                owner: "zed-industries".into(),
381                repo: "zed".into(),
382            }
383        );
384
385        // Test with "scm" in the path
386        let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git";
387
388        let parsed_remote = Bitbucket::from_remote_url(remote_url)
389            .unwrap()
390            .parse_remote_url(remote_url)
391            .unwrap();
392
393        assert_eq!(
394            parsed_remote,
395            ParsedGitRemote {
396                owner: "zed-industries".into(),
397                repo: "zed".into(),
398            }
399        );
400
401        // Test with only "scm" as owner
402        let remote_url = "https://bitbucket.company.com/scm/zed.git";
403
404        let parsed_remote = Bitbucket::from_remote_url(remote_url)
405            .unwrap()
406            .parse_remote_url(remote_url)
407            .unwrap();
408
409        assert_eq!(
410            parsed_remote,
411            ParsedGitRemote {
412                owner: "scm".into(),
413                repo: "zed".into(),
414            }
415        );
416    }
417
418    #[test]
419    fn test_parse_remote_url_given_self_hosted_https_url_with_username() {
420        let remote_url = "https://thorstenballzed@bitbucket.company.com/zed-industries/zed.git";
421
422        let parsed_remote = Bitbucket::from_remote_url(remote_url)
423            .unwrap()
424            .parse_remote_url(remote_url)
425            .unwrap();
426
427        assert_eq!(
428            parsed_remote,
429            ParsedGitRemote {
430                owner: "zed-industries".into(),
431                repo: "zed".into(),
432            }
433        );
434    }
435
436    #[test]
437    fn test_build_bitbucket_permalink() {
438        let permalink = Bitbucket::public_instance().build_permalink(
439            ParsedGitRemote {
440                owner: "zed-industries".into(),
441                repo: "zed".into(),
442            },
443            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
444        );
445
446        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
447        assert_eq!(permalink.to_string(), expected_url.to_string())
448    }
449
450    #[test]
451    fn test_build_bitbucket_self_hosted_permalink() {
452        let permalink =
453            Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
454                .unwrap()
455                .build_permalink(
456                    ParsedGitRemote {
457                        owner: "zed-industries".into(),
458                        repo: "zed".into(),
459                    },
460                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
461                );
462
463        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r";
464        assert_eq!(permalink.to_string(), expected_url.to_string())
465    }
466
467    #[test]
468    fn test_build_bitbucket_permalink_with_single_line_selection() {
469        let permalink = Bitbucket::public_instance().build_permalink(
470            ParsedGitRemote {
471                owner: "zed-industries".into(),
472                repo: "zed".into(),
473            },
474            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
475        );
476
477        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
478        assert_eq!(permalink.to_string(), expected_url.to_string())
479    }
480
481    #[test]
482    fn test_build_bitbucket_self_hosted_permalink_with_single_line_selection() {
483        let permalink =
484            Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
485                .unwrap()
486                .build_permalink(
487                    ParsedGitRemote {
488                        owner: "zed-industries".into(),
489                        repo: "zed".into(),
490                    },
491                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
492                );
493
494        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#7";
495        assert_eq!(permalink.to_string(), expected_url.to_string())
496    }
497
498    #[test]
499    fn test_build_bitbucket_permalink_with_multi_line_selection() {
500        let permalink = Bitbucket::public_instance().build_permalink(
501            ParsedGitRemote {
502                owner: "zed-industries".into(),
503                repo: "zed".into(),
504            },
505            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
506        );
507
508        let expected_url =
509            "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
510        assert_eq!(permalink.to_string(), expected_url.to_string())
511    }
512
513    #[test]
514    fn test_build_bitbucket_self_hosted_permalink_with_multi_line_selection() {
515        let permalink =
516            Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
517                .unwrap()
518                .build_permalink(
519                    ParsedGitRemote {
520                        owner: "zed-industries".into(),
521                        repo: "zed".into(),
522                    },
523                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
524                );
525
526        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#24-48";
527        assert_eq!(permalink.to_string(), expected_url.to_string())
528    }
529
530    #[test]
531    fn test_bitbucket_pull_requests() {
532        use indoc::indoc;
533
534        let remote = ParsedGitRemote {
535            owner: "zed-industries".into(),
536            repo: "zed".into(),
537        };
538
539        let bitbucket = Bitbucket::public_instance();
540
541        // Test message without PR reference
542        let message = "This does not contain a pull request";
543        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
544
545        // Pull request number at end of first line
546        let message = indoc! {r#"
547            Merged in feature-branch (pull request #123)
548
549            Some detailed description of the changes.
550        "#};
551
552        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
553        assert_eq!(pr.number, 123);
554        assert_eq!(
555            pr.url.as_str(),
556            "https://bitbucket.org/zed-industries/zed/pull-requests/123"
557        );
558    }
559
560    #[test]
561    fn test_bitbucket_self_hosted_pull_requests() {
562        use indoc::indoc;
563
564        let remote = ParsedGitRemote {
565            owner: "zed-industries".into(),
566            repo: "zed".into(),
567        };
568
569        let bitbucket =
570            Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
571                .unwrap();
572
573        // Test message without PR reference
574        let message = "This does not contain a pull request";
575        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
576
577        // Pull request number at end of first line
578        let message = indoc! {r#"
579            Merged in feature-branch (pull request #123)
580
581            Some detailed description of the changes.
582        "#};
583
584        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
585        assert_eq!(pr.number, 123);
586        assert_eq!(
587            pr.url.as_str(),
588            "https://bitbucket.company.com/projects/zed-industries/repos/zed/pull-requests/123"
589        );
590    }
591}