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}