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 pretty_assertions::assert_eq;
130
131 use super::*;
132
133 #[test]
134 fn test_parse_remote_url_given_ssh_url() {
135 let parsed_remote = Bitbucket::public_instance()
136 .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
137 .unwrap();
138
139 assert_eq!(
140 parsed_remote,
141 ParsedGitRemote {
142 owner: "zed-industries".into(),
143 repo: "zed".into(),
144 }
145 );
146 }
147
148 #[test]
149 fn test_parse_remote_url_given_https_url() {
150 let parsed_remote = Bitbucket::public_instance()
151 .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
152 .unwrap();
153
154 assert_eq!(
155 parsed_remote,
156 ParsedGitRemote {
157 owner: "zed-industries".into(),
158 repo: "zed".into(),
159 }
160 );
161 }
162
163 #[test]
164 fn test_parse_remote_url_given_https_url_with_username() {
165 let parsed_remote = Bitbucket::public_instance()
166 .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
167 .unwrap();
168
169 assert_eq!(
170 parsed_remote,
171 ParsedGitRemote {
172 owner: "zed-industries".into(),
173 repo: "zed".into(),
174 }
175 );
176 }
177
178 #[test]
179 fn test_build_bitbucket_permalink() {
180 let permalink = Bitbucket::public_instance().build_permalink(
181 ParsedGitRemote {
182 owner: "zed-industries".into(),
183 repo: "zed".into(),
184 },
185 BuildPermalinkParams {
186 sha: "f00b4r",
187 path: "main.rs",
188 selection: None,
189 },
190 );
191
192 let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
193 assert_eq!(permalink.to_string(), expected_url.to_string())
194 }
195
196 #[test]
197 fn test_build_bitbucket_permalink_with_single_line_selection() {
198 let permalink = Bitbucket::public_instance().build_permalink(
199 ParsedGitRemote {
200 owner: "zed-industries".into(),
201 repo: "zed".into(),
202 },
203 BuildPermalinkParams {
204 sha: "f00b4r",
205 path: "main.rs",
206 selection: Some(6..6),
207 },
208 );
209
210 let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
211 assert_eq!(permalink.to_string(), expected_url.to_string())
212 }
213
214 #[test]
215 fn test_build_bitbucket_permalink_with_multi_line_selection() {
216 let permalink = Bitbucket::public_instance().build_permalink(
217 ParsedGitRemote {
218 owner: "zed-industries".into(),
219 repo: "zed".into(),
220 },
221 BuildPermalinkParams {
222 sha: "f00b4r",
223 path: "main.rs",
224 selection: Some(23..47),
225 },
226 );
227
228 let expected_url =
229 "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
230 assert_eq!(permalink.to_string(), expected_url.to_string())
231 }
232
233 #[test]
234 fn test_bitbucket_pull_requests() {
235 use indoc::indoc;
236
237 let remote = ParsedGitRemote {
238 owner: "zed-industries".into(),
239 repo: "zed".into(),
240 };
241
242 let bitbucket = Bitbucket::public_instance();
243
244 // Test message without PR reference
245 let message = "This does not contain a pull request";
246 assert!(bitbucket.extract_pull_request(&remote, message).is_none());
247
248 // Pull request number at end of first line
249 let message = indoc! {r#"
250 Merged in feature-branch (pull request #123)
251
252 Some detailed description of the changes.
253 "#};
254
255 let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
256 assert_eq!(pr.number, 123);
257 assert_eq!(
258 pr.url.as_str(),
259 "https://bitbucket.org/zed-industries/zed/pull-requests/123"
260 );
261 }
262}