1use async_stream::stream as async_stream;
2use futures::{Stream, StreamExt, stream};
3use reqwest_middleware::ClientWithMiddleware;
4use serde::{Deserialize, Serialize};
5
6use super::{Debug, MAX_PAGE_SIZE, RemoteClient, RemoteCommit, RemotePullRequest};
7use crate::config::Remote;
8use crate::error::{Error, Result};
9
10pub(crate) const TEMPLATE_VARIABLES: &[&str] = &[
12 "azure_devops",
13 "commit.azure_devops",
14 "commit.remote",
15 "remote.azure_devops",
16];
17
18#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct AzureDevOpsCommit {
23 #[serde(rename = "commitId")]
25 pub commit_id: String,
26 pub author: Option<AzureDevOpsCommitAuthor>,
28 pub committer: Option<AzureDevOpsCommitAuthor>,
30}
31
32impl RemoteCommit for AzureDevOpsCommit {
33 fn id(&self) -> String {
34 self.commit_id.clone()
35 }
36
37 fn username(&self) -> Option<String> {
38 self.author.clone().and_then(|v| v.name)
39 }
40
41 fn timestamp(&self) -> Option<i64> {
42 self.author
43 .clone()
44 .and_then(|v| v.date)
45 .map(|date| self.convert_to_unix_timestamp(&date))
46 }
47}
48
49#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct AzureDevOpsCommitsResponse {
54 pub value: Vec<AzureDevOpsCommit>,
56 pub count: i64,
58}
59
60#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct AzureDevOpsCommitAuthor {
63 pub name: Option<String>,
65 pub email: Option<String>,
67 pub date: Option<String>,
69}
70
71#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct AzureDevOpsPullRequest {
76 #[serde(rename = "pullRequestId")]
78 pub pull_request_id: i64,
79 pub title: Option<String>,
81 pub status: String,
83 #[serde(rename = "createdBy")]
85 pub created_by: Option<AzureDevOpsUser>,
86 #[serde(rename = "lastMergeCommit")]
88 pub last_merge_commit: Option<AzureDevOpsCommitRef>,
89 #[serde(default)]
91 pub labels: Vec<AzureDevOpsPullRequestLabel>,
92}
93
94impl RemotePullRequest for AzureDevOpsPullRequest {
95 fn number(&self) -> i64 {
96 self.pull_request_id
97 }
98
99 fn title(&self) -> Option<String> {
100 self.title.clone()
101 }
102
103 fn author(&self) -> Option<String> {
104 self.created_by.clone().and_then(|v| v.display_name)
105 }
106
107 fn labels(&self) -> Vec<String> {
108 self.labels.iter().map(|v| v.name.clone()).collect()
109 }
110
111 fn merge_commit(&self) -> Option<String> {
112 self.last_merge_commit.clone().and_then(|v| v.commit_id)
113 }
114}
115
116#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct AzureDevOpsPullRequestsResponse {
121 pub value: Vec<AzureDevOpsPullRequest>,
123 pub count: i64,
125}
126
127#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct AzureDevOpsPullRequestLabel {
130 pub name: String,
132}
133
134#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct AzureDevOpsCommitRef {
137 #[serde(rename = "commitId")]
139 pub commit_id: Option<String>,
140}
141
142#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct AzureDevOpsUser {
145 #[serde(rename = "displayName")]
147 pub display_name: Option<String>,
148 #[serde(rename = "uniqueName")]
150 pub unique_name: Option<String>,
151}
152
153#[derive(Debug, Clone)]
155pub struct AzureDevOpsClient {
156 remote: Remote,
158 client: ClientWithMiddleware,
160}
161
162impl TryFrom<Remote> for AzureDevOpsClient {
164 type Error = Error;
165 fn try_from(remote: Remote) -> Result<Self> {
166 Ok(Self {
167 client: remote.create_client("application/json")?,
168 remote,
169 })
170 }
171}
172
173impl RemoteClient for AzureDevOpsClient {
174 const API_URL: &'static str = "https://dev.azure.com";
175 const API_URL_ENV: &'static str = "AZURE_DEVOPS_API_URL";
176
177 fn remote(&self) -> Remote {
178 self.remote.clone()
179 }
180
181 fn client(&self) -> ClientWithMiddleware {
182 self.client.clone()
183 }
184}
185
186impl AzureDevOpsClient {
187 fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
189 let skip = page * MAX_PAGE_SIZE;
190 let mut url = format!(
191 "{}/{}/_apis/git/repositories/{}/commits?api-version=7.1&$top={}&$skip={}",
192 api_url,
193 urlencoding::encode(&remote.owner),
194 urlencoding::encode(&remote.repo),
195 MAX_PAGE_SIZE,
196 skip
197 );
198
199 if let Some(ref_name) = ref_name {
200 url.push_str(&format!(
201 "&searchCriteria.itemVersion.versionType=tag&searchCriteria.itemVersion.version={}",
202 urlencoding::encode(ref_name)
203 ));
204 }
205
206 url
207 }
208
209 fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
211 let skip = page * MAX_PAGE_SIZE;
212 format!(
213 "{}/{}/_apis/git/repositories/{}/pullrequests?api-version=7.1&searchCriteria.\
214 status=completed&$top={}&$skip={}",
215 api_url,
216 urlencoding::encode(&remote.owner),
217 urlencoding::encode(&remote.repo),
218 MAX_PAGE_SIZE,
219 skip
220 )
221 }
222
223 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
227 pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
228 use futures::TryStreamExt;
229 crate::set_progress_message!("Fetching all commits from Azure DevOps");
230 self.get_commit_stream(ref_name).try_collect().await
231 }
232
233 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
237 pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
238 use futures::TryStreamExt;
239 crate::set_progress_message!("Fetching all pull requests from Azure DevOps");
240 self.get_pull_request_stream().try_collect().await
241 }
242
243 fn get_commit_stream(
244 &self,
245 ref_name: Option<&str>,
246 ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
247 let ref_name = ref_name.map(ToString::to_string);
248 async_stream! {
249 let page_stream = stream::iter(0..)
250 .map(|page| {
251 let ref_name = ref_name.clone();
252 async move {
253 let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
254 self.get_json::<AzureDevOpsCommitsResponse>(&url).await
255 }
256 })
257 .buffered(10);
258
259 let mut page_stream = Box::pin(page_stream);
260
261 while let Some(page_result) = page_stream.next().await {
262 match page_result {
263 Ok(response) => {
264 if response.value.is_empty() {
265 break;
266 }
267
268 for commit in response.value {
269 yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
270 }
271 }
272 Err(e) => {
273 yield Err(e);
274 break;
275 }
276 }
277 }
278 }
279 }
280
281 fn get_pull_request_stream(
282 &self,
283 ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
284 async_stream! {
285 let page_stream = stream::iter(0..)
286 .map(|page| async move {
287 let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
288 self.get_json::<AzureDevOpsPullRequestsResponse>(&url).await
289 })
290 .buffered(5);
291
292 let mut page_stream = Box::pin(page_stream);
293
294 while let Some(page_result) = page_stream.next().await {
295 match page_result {
296 Ok(response) => {
297 if response.value.is_empty() {
298 break;
299 }
300
301 for pr in response.value {
302 yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
303 }
304 }
305 Err(e) => {
306 yield Err(e);
307 break;
308 }
309 }
310 }
311 }
312 }
313}
314
315#[cfg(test)]
316#[allow(clippy::unwrap_used)]
317mod test {
318 use pretty_assertions::assert_eq;
319
320 use super::*;
321 use crate::config::Remote;
322 use crate::remote::RemotePullRequest;
323
324 #[test]
325 fn commits_url() {
326 let remote = Remote {
327 owner: String::from("myorg/myproject"),
328 repo: String::from("myrepo"),
329 token: None,
330 is_custom: false,
331 api_url: None,
332 http_timeout: std::time::Duration::from_secs(30),
333 native_tls: None,
334 };
335
336 let url = AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, None, 0);
337
338 assert_eq!(
339 "https://dev.azure.com/myorg%2Fmyproject/_apis/git/repositories/myrepo/commits?api-version=7.1&$top=100&$skip=0",
340 url
341 );
342 }
343
344 #[test]
345 fn commits_url_with_tag() {
346 let remote = Remote {
347 owner: String::from("myorg/myproject"),
348 repo: String::from("myrepo"),
349 token: None,
350 is_custom: false,
351 api_url: None,
352 http_timeout: std::time::Duration::from_secs(30),
353 native_tls: None,
354 };
355
356 let url =
357 AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, Some("v1.0.0"), 0);
358
359 assert!(url.contains("searchCriteria.itemVersion.versionType=tag"));
360 assert!(url.contains("searchCriteria.itemVersion.version=v1.0.0"));
361 }
362
363 #[test]
364 fn commits_url_pagination() {
365 let remote = Remote {
366 owner: String::from("org/proj"),
367 repo: String::from("repo"),
368 token: None,
369 is_custom: false,
370 api_url: None,
371 http_timeout: std::time::Duration::from_secs(30),
372 native_tls: None,
373 };
374
375 let url = AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, None, 2);
376
377 assert!(url.contains("$skip=200"));
378 assert!(url.contains("$top=100"));
379 }
380
381 #[test]
382 fn pull_requests_url() {
383 let remote = Remote {
384 owner: String::from("myorg/myproject"),
385 repo: String::from("myrepo"),
386 token: None,
387 is_custom: false,
388 api_url: None,
389 http_timeout: std::time::Duration::from_secs(30),
390 native_tls: None,
391 };
392
393 let url = AzureDevOpsClient::pull_requests_url("https://dev.azure.com", &remote, 0);
394
395 assert!(url.contains("pullrequests"));
396 assert!(url.contains("searchCriteria.status=completed"));
397 assert!(url.contains("$top=100"));
398 assert!(url.contains("$skip=0"));
399 }
400
401 #[test]
402 fn client_try_from_remote() {
403 let remote = Remote {
404 owner: String::from("myorg/myproject"),
405 repo: String::from("myrepo"),
406 token: None,
407 is_custom: false,
408 api_url: None,
409 http_timeout: std::time::Duration::from_secs(30),
410 native_tls: None,
411 };
412
413 let client = AzureDevOpsClient::try_from(remote.clone());
414 assert!(client.is_ok());
415
416 let client = client.unwrap();
417 assert_eq!(remote.owner, client.remote().owner);
418 assert_eq!(remote.repo, client.remote().repo);
419 }
420
421 #[test]
422 fn pull_request_with_commit_ref_no_commit_id() {
423 let pr = AzureDevOpsPullRequest {
424 pull_request_id: 1,
425 title: Some(String::from("test")),
426 status: String::from("completed"),
427 created_by: None,
428 last_merge_commit: Some(AzureDevOpsCommitRef { commit_id: None }),
429 labels: vec![],
430 };
431
432 assert_eq!(None, pr.merge_commit());
433 }
434
435 #[test]
436 fn pull_request_author() {
437 let pull_request: AzureDevOpsPullRequest = serde_json::from_str(
438 r#"{
439 "pullRequestId": 42,
440 "title": "feat: add pr_author",
441 "status": "completed",
442 "createdBy": { "displayName": "contributor" },
443 "lastMergeCommit": {
444 "commitId": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071"
445 }
446 }"#,
447 )
448 .expect("failed to deserialize pull request");
449
450 assert_eq!(Some(String::from("contributor")), pull_request.author());
451 }
452
453 #[test]
454 fn pull_request_author_missing() {
455 let pull_request: AzureDevOpsPullRequest = serde_json::from_str(
456 r#"{
457 "pullRequestId": 42,
458 "title": "feat: add pr_author",
459 "status": "completed",
460 "lastMergeCommit": {
461 "commitId": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071"
462 }
463 }"#,
464 )
465 .expect("failed to deserialize pull request");
466
467 assert_eq!(None, pull_request.author());
468 }
469}