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 labels(&self) -> Vec<String> {
104 self.labels.iter().map(|v| v.name.clone()).collect()
105 }
106
107 fn merge_commit(&self) -> Option<String> {
108 self.last_merge_commit.clone().and_then(|v| v.commit_id)
109 }
110}
111
112#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct AzureDevOpsPullRequestsResponse {
117 pub value: Vec<AzureDevOpsPullRequest>,
119 pub count: i64,
121}
122
123#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct AzureDevOpsPullRequestLabel {
126 pub name: String,
128}
129
130#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct AzureDevOpsCommitRef {
133 #[serde(rename = "commitId")]
135 pub commit_id: Option<String>,
136}
137
138#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct AzureDevOpsUser {
141 #[serde(rename = "displayName")]
143 pub display_name: Option<String>,
144 #[serde(rename = "uniqueName")]
146 pub unique_name: Option<String>,
147}
148
149#[derive(Debug, Clone)]
151pub struct AzureDevOpsClient {
152 remote: Remote,
154 client: ClientWithMiddleware,
156}
157
158impl TryFrom<Remote> for AzureDevOpsClient {
160 type Error = Error;
161 fn try_from(remote: Remote) -> Result<Self> {
162 Ok(Self {
163 client: remote.create_client("application/json")?,
164 remote,
165 })
166 }
167}
168
169impl RemoteClient for AzureDevOpsClient {
170 const API_URL: &'static str = "https://dev.azure.com";
171 const API_URL_ENV: &'static str = "AZURE_DEVOPS_API_URL";
172
173 fn remote(&self) -> Remote {
174 self.remote.clone()
175 }
176
177 fn client(&self) -> ClientWithMiddleware {
178 self.client.clone()
179 }
180}
181
182impl AzureDevOpsClient {
183 fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
185 let skip = page * MAX_PAGE_SIZE;
186 let mut url = format!(
187 "{}/{}/_apis/git/repositories/{}/commits?api-version=7.1&$top={}&$skip={}",
188 api_url,
189 urlencoding::encode(&remote.owner),
190 urlencoding::encode(&remote.repo),
191 MAX_PAGE_SIZE,
192 skip
193 );
194
195 if let Some(ref_name) = ref_name {
196 url.push_str(&format!(
197 "&searchCriteria.itemVersion.versionType=tag&searchCriteria.itemVersion.version={}",
198 urlencoding::encode(ref_name)
199 ));
200 }
201
202 url
203 }
204
205 fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
207 let skip = page * MAX_PAGE_SIZE;
208 format!(
209 "{}/{}/_apis/git/repositories/{}/pullrequests?api-version=7.1&searchCriteria.\
210 status=completed&$top={}&$skip={}",
211 api_url,
212 urlencoding::encode(&remote.owner),
213 urlencoding::encode(&remote.repo),
214 MAX_PAGE_SIZE,
215 skip
216 )
217 }
218
219 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
223 pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
224 use futures::TryStreamExt;
225 crate::set_progress_message!("Fetching all commits from Azure DevOps");
226 self.get_commit_stream(ref_name).try_collect().await
227 }
228
229 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
233 pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
234 use futures::TryStreamExt;
235 crate::set_progress_message!("Fetching all pull requests from Azure DevOps");
236 self.get_pull_request_stream().try_collect().await
237 }
238
239 fn get_commit_stream(
240 &self,
241 ref_name: Option<&str>,
242 ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
243 let ref_name = ref_name.map(ToString::to_string);
244 async_stream! {
245 let page_stream = stream::iter(0..)
246 .map(|page| {
247 let ref_name = ref_name.clone();
248 async move {
249 let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
250 self.get_json::<AzureDevOpsCommitsResponse>(&url).await
251 }
252 })
253 .buffered(10);
254
255 let mut page_stream = Box::pin(page_stream);
256
257 while let Some(page_result) = page_stream.next().await {
258 match page_result {
259 Ok(response) => {
260 if response.value.is_empty() {
261 break;
262 }
263
264 for commit in response.value {
265 yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
266 }
267 }
268 Err(e) => {
269 yield Err(e);
270 break;
271 }
272 }
273 }
274 }
275 }
276
277 fn get_pull_request_stream(
278 &self,
279 ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
280 async_stream! {
281 let page_stream = stream::iter(0..)
282 .map(|page| async move {
283 let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
284 self.get_json::<AzureDevOpsPullRequestsResponse>(&url).await
285 })
286 .buffered(5);
287
288 let mut page_stream = Box::pin(page_stream);
289
290 while let Some(page_result) = page_stream.next().await {
291 match page_result {
292 Ok(response) => {
293 if response.value.is_empty() {
294 break;
295 }
296
297 for pr in response.value {
298 yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
299 }
300 }
301 Err(e) => {
302 yield Err(e);
303 break;
304 }
305 }
306 }
307 }
308 }
309}
310
311#[cfg(test)]
312#[allow(clippy::unwrap_used)]
313mod test {
314 use pretty_assertions::assert_eq;
315
316 use super::*;
317 use crate::config::Remote;
318 use crate::remote::RemotePullRequest;
319
320 #[test]
321 fn commits_url() {
322 let remote = Remote {
323 owner: String::from("myorg/myproject"),
324 repo: String::from("myrepo"),
325 token: None,
326 is_custom: false,
327 api_url: None,
328 native_tls: None,
329 };
330
331 let url = AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, None, 0);
332
333 assert_eq!(
334 "https://dev.azure.com/myorg%2Fmyproject/_apis/git/repositories/myrepo/commits?api-version=7.1&$top=100&$skip=0",
335 url
336 );
337 }
338
339 #[test]
340 fn commits_url_with_tag() {
341 let remote = Remote {
342 owner: String::from("myorg/myproject"),
343 repo: String::from("myrepo"),
344 token: None,
345 is_custom: false,
346 api_url: None,
347 native_tls: None,
348 };
349
350 let url =
351 AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, Some("v1.0.0"), 0);
352
353 assert!(url.contains("searchCriteria.itemVersion.versionType=tag"));
354 assert!(url.contains("searchCriteria.itemVersion.version=v1.0.0"));
355 }
356
357 #[test]
358 fn commits_url_pagination() {
359 let remote = Remote {
360 owner: String::from("org/proj"),
361 repo: String::from("repo"),
362 token: None,
363 is_custom: false,
364 api_url: None,
365 native_tls: None,
366 };
367
368 let url = AzureDevOpsClient::commits_url("https://dev.azure.com", &remote, None, 2);
369
370 assert!(url.contains("$skip=200"));
371 assert!(url.contains("$top=100"));
372 }
373
374 #[test]
375 fn pull_requests_url() {
376 let remote = Remote {
377 owner: String::from("myorg/myproject"),
378 repo: String::from("myrepo"),
379 token: None,
380 is_custom: false,
381 api_url: None,
382 native_tls: None,
383 };
384
385 let url = AzureDevOpsClient::pull_requests_url("https://dev.azure.com", &remote, 0);
386
387 assert!(url.contains("pullrequests"));
388 assert!(url.contains("searchCriteria.status=completed"));
389 assert!(url.contains("$top=100"));
390 assert!(url.contains("$skip=0"));
391 }
392
393 #[test]
394 fn client_try_from_remote() {
395 let remote = Remote {
396 owner: String::from("myorg/myproject"),
397 repo: String::from("myrepo"),
398 token: None,
399 is_custom: false,
400 api_url: None,
401 native_tls: None,
402 };
403
404 let client = AzureDevOpsClient::try_from(remote.clone());
405 assert!(client.is_ok());
406
407 let client = client.unwrap();
408 assert_eq!(remote.owner, client.remote().owner);
409 assert_eq!(remote.repo, client.remote().repo);
410 }
411
412 #[test]
413 fn pull_request_with_commit_ref_no_commit_id() {
414 let pr = AzureDevOpsPullRequest {
415 pull_request_id: 1,
416 title: Some(String::from("test")),
417 status: String::from("completed"),
418 created_by: None,
419 last_merge_commit: Some(AzureDevOpsCommitRef { commit_id: None }),
420 labels: vec![],
421 };
422
423 assert_eq!(None, pr.merge_commit());
424 }
425}