1use std::str::FromStr;
2use std::sync::LazyLock;
3
4use async_trait::async_trait;
5use regex::Regex;
6use url::Url;
7
8use git::{
9 BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
10 PullRequest, RemoteUrl,
11};
12
13fn pull_request_regex() -> &'static Regex {
14 static PULL_REQUEST_REGEX: LazyLock<Regex> =
15 LazyLock::new(|| Regex::new(r"^Merged PR (\d+):").unwrap());
16 &PULL_REQUEST_REGEX
17}
18
19#[derive(Debug)]
20pub struct Azure;
21
22impl Azure {
23 fn parse_dev_azure_com_url(&self, url: &RemoteUrl) -> Option<ParsedGitRemote> {
24 let host = url.host_str()?;
25
26 if host == "ssh.dev.azure.com" {
27 // SSH format: git@ssh.dev.azure.com:v3/{organization}/{project}/{repo}
28 let mut path_segments = url.path_segments()?;
29 let _v3 = path_segments.next()?;
30 let organization = path_segments.next()?;
31 let project = path_segments.next()?;
32 let repo = path_segments.next()?.trim_end_matches(".git");
33
34 return Some(ParsedGitRemote {
35 owner: format!("{organization}/{project}").into(),
36 repo: repo.into(),
37 });
38 }
39
40 if host != "dev.azure.com" {
41 return None;
42 }
43
44 // HTTPS format: https://dev.azure.com/{organization}/{project}/_git/{repo}
45 // or: https://{organization}@dev.azure.com/{organization}/{project}/_git/{repo}
46 let mut path_segments = url.path_segments()?;
47 let organization = path_segments.next()?;
48 let project = path_segments.next()?;
49 let _git = path_segments.next()?;
50 let repo = path_segments.next()?.trim_end_matches(".git");
51
52 Some(ParsedGitRemote {
53 owner: format!("{organization}/{project}").into(),
54 repo: repo.into(),
55 })
56 }
57
58 fn parse_visualstudio_com_url(&self, url: &RemoteUrl) -> Option<ParsedGitRemote> {
59 let host = url.host_str()?;
60
61 if !host.ends_with(".visualstudio.com") {
62 return None;
63 }
64
65 let organization = host.strip_suffix(".visualstudio.com")?;
66
67 // HTTPS format: https://{organization}.visualstudio.com/{project}/_git/{repo}
68 // or with DefaultCollection: https://{organization}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
69 let mut path_segments = url.path_segments()?.peekable();
70
71 let first_segment = path_segments.next()?;
72 let project = if first_segment == "DefaultCollection" {
73 path_segments.next()?
74 } else {
75 first_segment
76 };
77
78 let _git = path_segments.next()?;
79 let repo = path_segments.next()?.trim_end_matches(".git");
80
81 Some(ParsedGitRemote {
82 owner: format!("{organization}/{project}").into(),
83 repo: repo.into(),
84 })
85 }
86}
87
88#[async_trait]
89impl GitHostingProvider for Azure {
90 fn name(&self) -> String {
91 "Azure DevOps".to_string()
92 }
93
94 fn base_url(&self) -> Url {
95 Url::parse("https://dev.azure.com").unwrap()
96 }
97
98 fn supports_avatars(&self) -> bool {
99 false
100 }
101
102 fn format_line_number(&self, line: u32) -> String {
103 format!("line={line}&lineEnd={line}&lineStartColumn=1&lineEndColumn=1")
104 }
105
106 fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
107 format!("line={start_line}&lineEnd={end_line}&lineStartColumn=1&lineEndColumn=1")
108 }
109
110 fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
111 let url = RemoteUrl::from_str(url).ok()?;
112
113 self.parse_dev_azure_com_url(&url)
114 .or_else(|| self.parse_visualstudio_com_url(&url))
115 }
116
117 fn build_commit_permalink(
118 &self,
119 remote: &ParsedGitRemote,
120 params: BuildCommitPermalinkParams,
121 ) -> Url {
122 let BuildCommitPermalinkParams { sha } = params;
123 let ParsedGitRemote { owner, repo } = remote;
124
125 let mut url = self
126 .base_url()
127 .join(&format!("{owner}/_git/{repo}/commit/{sha}"))
128 .expect("failed to build commit permalink");
129 url.set_query(None);
130 url
131 }
132
133 fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
134 let ParsedGitRemote { owner, repo } = remote;
135 let BuildPermalinkParams {
136 sha,
137 path,
138 selection,
139 } = params;
140
141 let mut permalink = self
142 .base_url()
143 .join(&format!("{owner}/_git/{repo}"))
144 .expect("failed to build permalink");
145
146 let mut query = format!("path=/{path}&version=GC{sha}");
147 if let Some(selection) = selection {
148 query.push('&');
149 query.push_str(&self.line_fragment(&selection));
150 }
151 permalink.set_query(Some(&query));
152
153 permalink
154 }
155
156 fn build_create_pull_request_url(
157 &self,
158 remote: &ParsedGitRemote,
159 source_branch: &str,
160 ) -> Option<Url> {
161 let ParsedGitRemote { owner, repo } = remote;
162 let encoded_source = urlencoding::encode(source_branch);
163
164 let mut url = self
165 .base_url()
166 .join(&format!("{owner}/_git/{repo}/pullrequestcreate"))
167 .ok()?;
168 url.set_query(Some(&format!("sourceRef={encoded_source}")));
169 Some(url)
170 }
171
172 fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
173 let first_line = message.lines().next()?;
174 let capture = pull_request_regex().captures(first_line)?;
175 let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
176
177 let ParsedGitRemote { owner, repo } = remote;
178 let url = self
179 .base_url()
180 .join(&format!("{owner}/_git/{repo}/pullrequest/{number}"))
181 .ok()?;
182
183 Some(PullRequest { number, url })
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use git::repository::repo_path;
190 use pretty_assertions::assert_eq;
191
192 use super::*;
193
194 #[test]
195 fn test_parse_remote_url_https_dev_azure() {
196 let parsed_remote = Azure
197 .parse_remote_url("https://dev.azure.com/myorg/myproject/_git/myrepo")
198 .unwrap();
199
200 assert_eq!(
201 parsed_remote,
202 ParsedGitRemote {
203 owner: "myorg/myproject".into(),
204 repo: "myrepo".into(),
205 }
206 );
207 }
208
209 #[test]
210 fn test_parse_remote_url_https_dev_azure_with_username() {
211 let parsed_remote = Azure
212 .parse_remote_url("https://myorg@dev.azure.com/myorg/myproject/_git/myrepo")
213 .unwrap();
214
215 assert_eq!(
216 parsed_remote,
217 ParsedGitRemote {
218 owner: "myorg/myproject".into(),
219 repo: "myrepo".into(),
220 }
221 );
222 }
223
224 #[test]
225 fn test_parse_remote_url_ssh_dev_azure() {
226 let parsed_remote = Azure
227 .parse_remote_url("git@ssh.dev.azure.com:v3/myorg/myproject/myrepo")
228 .unwrap();
229
230 assert_eq!(
231 parsed_remote,
232 ParsedGitRemote {
233 owner: "myorg/myproject".into(),
234 repo: "myrepo".into(),
235 }
236 );
237 }
238
239 #[test]
240 fn test_parse_remote_url_visualstudio_com() {
241 let parsed_remote = Azure
242 .parse_remote_url("https://myorg.visualstudio.com/myproject/_git/myrepo")
243 .unwrap();
244
245 assert_eq!(
246 parsed_remote,
247 ParsedGitRemote {
248 owner: "myorg/myproject".into(),
249 repo: "myrepo".into(),
250 }
251 );
252 }
253
254 #[test]
255 fn test_parse_remote_url_visualstudio_com_with_default_collection() {
256 let parsed_remote = Azure
257 .parse_remote_url(
258 "https://myorg.visualstudio.com/DefaultCollection/myproject/_git/myrepo",
259 )
260 .unwrap();
261
262 assert_eq!(
263 parsed_remote,
264 ParsedGitRemote {
265 owner: "myorg/myproject".into(),
266 repo: "myrepo".into(),
267 }
268 );
269 }
270
271 #[test]
272 fn test_parse_remote_url_returns_none_for_github() {
273 let result = Azure.parse_remote_url("https://github.com/owner/repo.git");
274 assert!(result.is_none());
275 }
276
277 #[test]
278 fn test_build_azure_permalink() {
279 let permalink = Azure.build_permalink(
280 ParsedGitRemote {
281 owner: "myorg/myproject".into(),
282 repo: "myrepo".into(),
283 },
284 BuildPermalinkParams::new("abc123def456", &repo_path("src/main.rs"), None),
285 );
286
287 let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo?path=/src/main.rs&version=GCabc123def456";
288 assert_eq!(permalink.to_string(), expected_url.to_string())
289 }
290
291 #[test]
292 fn test_build_azure_permalink_with_single_line_selection() {
293 let permalink = Azure.build_permalink(
294 ParsedGitRemote {
295 owner: "myorg/myproject".into(),
296 repo: "myrepo".into(),
297 },
298 BuildPermalinkParams::new("abc123def456", &repo_path("src/main.rs"), Some(6..6)),
299 );
300
301 let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo?path=/src/main.rs&version=GCabc123def456&line=7&lineEnd=7&lineStartColumn=1&lineEndColumn=1";
302 assert_eq!(permalink.to_string(), expected_url.to_string())
303 }
304
305 #[test]
306 fn test_build_azure_permalink_with_multi_line_selection() {
307 let permalink = Azure.build_permalink(
308 ParsedGitRemote {
309 owner: "myorg/myproject".into(),
310 repo: "myrepo".into(),
311 },
312 BuildPermalinkParams::new("abc123def456", &repo_path("src/main.rs"), Some(23..47)),
313 );
314
315 let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo?path=/src/main.rs&version=GCabc123def456&line=24&lineEnd=48&lineStartColumn=1&lineEndColumn=1";
316 assert_eq!(permalink.to_string(), expected_url.to_string())
317 }
318
319 #[test]
320 fn test_build_azure_commit_permalink() {
321 let permalink = Azure.build_commit_permalink(
322 &ParsedGitRemote {
323 owner: "myorg/myproject".into(),
324 repo: "myrepo".into(),
325 },
326 BuildCommitPermalinkParams {
327 sha: "abc123def456",
328 },
329 );
330
331 let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo/commit/abc123def456";
332 assert_eq!(permalink.to_string(), expected_url.to_string())
333 }
334
335 #[test]
336 fn test_build_azure_create_pr_url() {
337 let remote = ParsedGitRemote {
338 owner: "myorg/myproject".into(),
339 repo: "myrepo".into(),
340 };
341
342 let url = Azure
343 .build_create_pull_request_url(&remote, "feature/my-branch")
344 .expect("url should be constructed");
345
346 assert_eq!(
347 url.as_str(),
348 "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fmy-branch"
349 );
350 }
351
352 #[test]
353 fn test_azure_extract_pull_request() {
354 use indoc::indoc;
355
356 let remote = ParsedGitRemote {
357 owner: "myorg/myproject".into(),
358 repo: "myrepo".into(),
359 };
360
361 let message = "This does not contain a pull request";
362 assert!(Azure.extract_pull_request(&remote, message).is_none());
363
364 let message = indoc! {r#"
365 Merged PR 123: Add new feature
366
367 This PR adds a new feature to the application.
368 "#};
369
370 let pull_request = Azure.extract_pull_request(&remote, message).unwrap();
371 assert_eq!(pull_request.number, 123);
372 assert_eq!(
373 pull_request.url.as_str(),
374 "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/123"
375 );
376
377 let message = "Merged PR 456: Fix bug in authentication";
378 let pull_request = Azure.extract_pull_request(&remote, message).unwrap();
379 assert_eq!(pull_request.number, 456);
380 assert_eq!(
381 pull_request.url.as_str(),
382 "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/456"
383 );
384
385 let message = "This mentions PR 789 but not at the start";
386 assert!(Azure.extract_pull_request(&remote, message).is_none());
387 }
388}