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}