1use std::str::FromStr;
2use std::sync::LazyLock;
3
4use regex::Regex;
5use url::Url;
6
7use git::{
8 BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
9 PullRequest, RemoteUrl,
10};
11
12fn pull_request_regex() -> &'static Regex {
13 static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
14 // This matches Bitbucket PR reference pattern: (pull request #xxx)
15 Regex::new(r"\(pull request #(\d+)\)").unwrap()
16 });
17 &PULL_REQUEST_REGEX
18}
19
20pub struct Bitbucket {
21 name: String,
22 base_url: Url,
23}
24
25impl Bitbucket {
26 pub fn new(name: impl Into<String>, base_url: Url) -> Self {
27 Self {
28 name: name.into(),
29 base_url,
30 }
31 }
32
33 pub fn public_instance() -> Self {
34 Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap())
35 }
36}
37
38impl GitHostingProvider for Bitbucket {
39 fn name(&self) -> String {
40 self.name.clone()
41 }
42
43 fn base_url(&self) -> Url {
44 self.base_url.clone()
45 }
46
47 fn supports_avatars(&self) -> bool {
48 false
49 }
50
51 fn format_line_number(&self, line: u32) -> String {
52 format!("lines-{line}")
53 }
54
55 fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
56 format!("lines-{start_line}:{end_line}")
57 }
58
59 fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
60 let url = RemoteUrl::from_str(url).ok()?;
61
62 let host = url.host_str()?;
63 if host != "bitbucket.org" {
64 return None;
65 }
66
67 let mut path_segments = url.path_segments()?;
68 let owner = path_segments.next()?;
69 let repo = path_segments.next()?.trim_end_matches(".git");
70
71 Some(ParsedGitRemote {
72 owner: owner.into(),
73 repo: repo.into(),
74 })
75 }
76
77 fn build_commit_permalink(
78 &self,
79 remote: &ParsedGitRemote,
80 params: BuildCommitPermalinkParams,
81 ) -> Url {
82 let BuildCommitPermalinkParams { sha } = params;
83 let ParsedGitRemote { owner, repo } = remote;
84
85 self.base_url()
86 .join(&format!("{owner}/{repo}/commits/{sha}"))
87 .unwrap()
88 }
89
90 fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
91 let ParsedGitRemote { owner, repo } = remote;
92 let BuildPermalinkParams {
93 sha,
94 path,
95 selection,
96 } = params;
97
98 let mut permalink = self
99 .base_url()
100 .join(&format!("{owner}/{repo}/src/{sha}/{path}"))
101 .unwrap();
102 permalink.set_fragment(
103 selection
104 .map(|selection| self.line_fragment(&selection))
105 .as_deref(),
106 );
107 permalink
108 }
109
110 fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
111 // Check first line of commit message for PR references
112 let first_line = message.lines().next()?;
113
114 // Try to match against our PR patterns
115 let capture = pull_request_regex().captures(first_line)?;
116 let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
117
118 // Construct the PR URL in Bitbucket format
119 let mut url = self.base_url();
120 let path = format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number);
121 url.set_path(&path);
122
123 Some(PullRequest { number, url })
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use git::repository::repo_path;
130 use pretty_assertions::assert_eq;
131
132 use super::*;
133
134 #[test]
135 fn test_parse_remote_url_given_ssh_url() {
136 let parsed_remote = Bitbucket::public_instance()
137 .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
138 .unwrap();
139
140 assert_eq!(
141 parsed_remote,
142 ParsedGitRemote {
143 owner: "zed-industries".into(),
144 repo: "zed".into(),
145 }
146 );
147 }
148
149 #[test]
150 fn test_parse_remote_url_given_https_url() {
151 let parsed_remote = Bitbucket::public_instance()
152 .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
153 .unwrap();
154
155 assert_eq!(
156 parsed_remote,
157 ParsedGitRemote {
158 owner: "zed-industries".into(),
159 repo: "zed".into(),
160 }
161 );
162 }
163
164 #[test]
165 fn test_parse_remote_url_given_https_url_with_username() {
166 let parsed_remote = Bitbucket::public_instance()
167 .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
168 .unwrap();
169
170 assert_eq!(
171 parsed_remote,
172 ParsedGitRemote {
173 owner: "zed-industries".into(),
174 repo: "zed".into(),
175 }
176 );
177 }
178
179 #[test]
180 fn test_build_bitbucket_permalink() {
181 let permalink = Bitbucket::public_instance().build_permalink(
182 ParsedGitRemote {
183 owner: "zed-industries".into(),
184 repo: "zed".into(),
185 },
186 BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
187 );
188
189 let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
190 assert_eq!(permalink.to_string(), expected_url.to_string())
191 }
192
193 #[test]
194 fn test_build_bitbucket_permalink_with_single_line_selection() {
195 let permalink = Bitbucket::public_instance().build_permalink(
196 ParsedGitRemote {
197 owner: "zed-industries".into(),
198 repo: "zed".into(),
199 },
200 BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
201 );
202
203 let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
204 assert_eq!(permalink.to_string(), expected_url.to_string())
205 }
206
207 #[test]
208 fn test_build_bitbucket_permalink_with_multi_line_selection() {
209 let permalink = Bitbucket::public_instance().build_permalink(
210 ParsedGitRemote {
211 owner: "zed-industries".into(),
212 repo: "zed".into(),
213 },
214 BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
215 );
216
217 let expected_url =
218 "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
219 assert_eq!(permalink.to_string(), expected_url.to_string())
220 }
221
222 #[test]
223 fn test_bitbucket_pull_requests() {
224 use indoc::indoc;
225
226 let remote = ParsedGitRemote {
227 owner: "zed-industries".into(),
228 repo: "zed".into(),
229 };
230
231 let bitbucket = Bitbucket::public_instance();
232
233 // Test message without PR reference
234 let message = "This does not contain a pull request";
235 assert!(bitbucket.extract_pull_request(&remote, message).is_none());
236
237 // Pull request number at end of first line
238 let message = indoc! {r#"
239 Merged in feature-branch (pull request #123)
240
241 Some detailed description of the changes.
242 "#};
243
244 let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
245 assert_eq!(pr.number, 123);
246 assert_eq!(
247 pr.url.as_str(),
248 "https://bitbucket.org/zed-industries/zed/pull-requests/123"
249 );
250 }
251}