azure.rs

  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}