Skip to main content

git_cliff_core/remote/
azure_devops.rs

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
10/// Template variables related to this remote.
11pub(crate) const TEMPLATE_VARIABLES: &[&str] = &[
12    "azure_devops",
13    "commit.azure_devops",
14    "commit.remote",
15    "remote.azure_devops",
16];
17
18/// Representation of a single commit.
19///
20/// <https://learn.microsoft.com/en-us/rest/api/azure/devops/git/commits/get-commits>
21#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct AzureDevOpsCommit {
23    /// Commit ID (SHA-1).
24    #[serde(rename = "commitId")]
25    pub commit_id: String,
26    /// Author of the commit.
27    pub author: Option<AzureDevOpsCommitAuthor>,
28    /// Committer of the commit.
29    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/// Azure DevOps commits API response wrapper.
50///
51/// <https://learn.microsoft.com/en-us/rest/api/azure/devops/git/commits/get-commits>
52#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct AzureDevOpsCommitsResponse {
54    /// List of commits.
55    pub value: Vec<AzureDevOpsCommit>,
56    /// Number of commits in the response.
57    pub count: i64,
58}
59
60/// Author/Committer of the commit.
61#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct AzureDevOpsCommitAuthor {
63    /// Name of the author/committer.
64    pub name: Option<String>,
65    /// Email of the author/committer.
66    pub email: Option<String>,
67    /// Date of the commit.
68    pub date: Option<String>,
69}
70
71/// Representation of a single pull request.
72///
73/// <https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests>
74#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct AzureDevOpsPullRequest {
76    /// Pull request ID.
77    #[serde(rename = "pullRequestId")]
78    pub pull_request_id: i64,
79    /// Pull request title.
80    pub title: Option<String>,
81    /// Status of the pull request.
82    pub status: String,
83    /// Created by user.
84    #[serde(rename = "createdBy")]
85    pub created_by: Option<AzureDevOpsUser>,
86    /// Last merge commit.
87    #[serde(rename = "lastMergeCommit")]
88    pub last_merge_commit: Option<AzureDevOpsCommitRef>,
89    /// Labels associated with the pull request.
90    #[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/// Azure DevOps pull requests API response wrapper.
113///
114/// <https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests>
115#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct AzureDevOpsPullRequestsResponse {
117    /// List of pull requests.
118    pub value: Vec<AzureDevOpsPullRequest>,
119    /// Number of pull requests in the response.
120    pub count: i64,
121}
122
123/// Label of the pull request.
124#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct AzureDevOpsPullRequestLabel {
126    /// Name of the label.
127    pub name: String,
128}
129
130/// Representation of a commit reference.
131#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct AzureDevOpsCommitRef {
133    /// Commit ID (SHA-1).
134    #[serde(rename = "commitId")]
135    pub commit_id: Option<String>,
136}
137
138/// Representation of an Azure DevOps user.
139#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct AzureDevOpsUser {
141    /// Display name of the user.
142    #[serde(rename = "displayName")]
143    pub display_name: Option<String>,
144    /// Unique name of the user.
145    #[serde(rename = "uniqueName")]
146    pub unique_name: Option<String>,
147}
148
149/// HTTP client for handling Azure DevOps REST API requests.
150#[derive(Debug, Clone)]
151pub struct AzureDevOpsClient {
152    /// Remote.
153    remote: Remote,
154    /// HTTP client.
155    client: ClientWithMiddleware,
156}
157
158/// Constructs an Azure DevOps client from the remote configuration.
159impl 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    /// Constructs the URL for Azure DevOps commits API.
184    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    /// Constructs the URL for Azure DevOps pull requests API.
206    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    /// Fetches the complete list of commits.
220    /// This is inefficient for large repositories; consider using
221    /// `get_commit_stream` instead.
222    #[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    /// Fetches the complete list of pull requests.
230    /// This is inefficient for large repositories; consider using
231    /// `get_pull_request_stream` instead.
232    #[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}