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 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/// Azure DevOps pull requests API response wrapper.
117///
118/// <https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests>
119#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct AzureDevOpsPullRequestsResponse {
121    /// List of pull requests.
122    pub value: Vec<AzureDevOpsPullRequest>,
123    /// Number of pull requests in the response.
124    pub count: i64,
125}
126
127/// Label of the pull request.
128#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct AzureDevOpsPullRequestLabel {
130    /// Name of the label.
131    pub name: String,
132}
133
134/// Representation of a commit reference.
135#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct AzureDevOpsCommitRef {
137    /// Commit ID (SHA-1).
138    #[serde(rename = "commitId")]
139    pub commit_id: Option<String>,
140}
141
142/// Representation of an Azure DevOps user.
143#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct AzureDevOpsUser {
145    /// Display name of the user.
146    #[serde(rename = "displayName")]
147    pub display_name: Option<String>,
148    /// Unique name of the user.
149    #[serde(rename = "uniqueName")]
150    pub unique_name: Option<String>,
151}
152
153/// HTTP client for handling Azure DevOps REST API requests.
154#[derive(Debug, Clone)]
155pub struct AzureDevOpsClient {
156    /// Remote.
157    remote: Remote,
158    /// HTTP client.
159    client: ClientWithMiddleware,
160}
161
162/// Constructs an Azure DevOps client from the remote configuration.
163impl 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    /// Constructs the URL for Azure DevOps commits API.
188    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    /// Constructs the URL for Azure DevOps pull requests API.
210    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    /// Fetches the complete list of commits.
224    /// This is inefficient for large repositories; consider using
225    /// `get_commit_stream` instead.
226    #[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    /// Fetches the complete list of pull requests.
234    /// This is inefficient for large repositories; consider using
235    /// `get_pull_request_stream` instead.
236    #[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}