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}