Skip to main content

git_cliff_core/remote/
bitbucket.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] = &["bitbucket", "commit.bitbucket", "commit.remote"];
12
13/// Maximum number of entries to fetch for bitbucket pull requests.
14pub(crate) const BITBUCKET_MAX_PAGE_PRS: usize = 50;
15
16/// Representation of a single commit.
17#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct BitbucketCommit {
19    /// SHA.
20    pub hash: String,
21    /// Date of the commit
22    pub date: String,
23    /// Author of the commit.
24    pub author: Option<BitbucketCommitAuthor>,
25}
26
27impl RemoteCommit for BitbucketCommit {
28    fn id(&self) -> String {
29        self.hash.clone()
30    }
31
32    fn username(&self) -> Option<String> {
33        self.author.clone().and_then(|v| v.login)
34    }
35
36    fn timestamp(&self) -> Option<i64> {
37        Some(self.convert_to_unix_timestamp(self.date.clone().as_str()))
38    }
39}
40
41/// Bitbucket Pagination Header
42///
43/// <https://developer.atlassian.com/cloud/bitbucket/rest/intro/#pagination>
44#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct BitbucketPagination<T> {
46    /// Total number of objects in the response.
47    pub size: Option<i64>,
48    /// Page number of the current results.
49    pub page: Option<i64>,
50    /// Current number of objects on the existing page.  Globally, the minimum
51    /// length is 10 and the maximum is 100.
52    pub pagelen: Option<i64>,
53    /// Link to the next page if it exists.
54    pub next: Option<String>,
55    /// Link to the previous page if it exists.
56    pub previous: Option<String>,
57    /// List of Objects.
58    pub values: Vec<T>,
59}
60
61/// Author of a commit or a pull request.
62///
63/// A commit carries the raw `Name <email>` string; a pull request carries an
64/// account object, whose handle is `nickname`.
65#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct BitbucketCommitAuthor {
67    /// Raw `Name <email>` string, set on commit authors.
68    #[serde(rename = "raw")]
69    pub login: Option<String>,
70    /// Account handle, set on pull request authors.
71    pub nickname: Option<String>,
72}
73
74/// Label of the pull request.
75#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct PullRequestLabel {
78    /// Name of the label.
79    pub name: String,
80}
81
82/// Representation of a single pull request's merge commit
83#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct BitbucketPullRequestMergeCommit {
85    /// SHA of the merge commit.
86    pub hash: String,
87}
88
89/// Representation of a single pull request.
90#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct BitbucketPullRequest {
92    /// Pull request number.
93    pub id: i64,
94    /// Pull request title.
95    pub title: Option<String>,
96    /// Bitbucket Pull Request Merge Commit
97    pub merge_commit: BitbucketPullRequestMergeCommit,
98    /// Author of Pull Request
99    pub author: BitbucketCommitAuthor,
100}
101
102impl RemotePullRequest for BitbucketPullRequest {
103    fn number(&self) -> i64 {
104        self.id
105    }
106
107    fn title(&self) -> Option<String> {
108        self.title.clone()
109    }
110
111    fn author(&self) -> Option<String> {
112        self.author.nickname.clone()
113    }
114
115    fn labels(&self) -> Vec<String> {
116        vec![]
117    }
118
119    fn merge_commit(&self) -> Option<String> {
120        Some(self.merge_commit.hash.clone())
121    }
122}
123
124/// HTTP client for handling Bitbucket REST API requests.
125#[derive(Debug, Clone)]
126pub struct BitbucketClient {
127    /// Remote.
128    remote: Remote,
129    /// HTTP client.
130    client: ClientWithMiddleware,
131}
132
133/// Constructs a Bitbucket client from the remote configuration.
134impl TryFrom<Remote> for BitbucketClient {
135    type Error = Error;
136    fn try_from(remote: Remote) -> Result<Self> {
137        Ok(Self {
138            client: remote.create_client("application/json")?,
139            remote,
140        })
141    }
142}
143
144impl RemoteClient for BitbucketClient {
145    const API_URL: &'static str = "https://api.bitbucket.org/2.0/repositories";
146    const API_URL_ENV: &'static str = "BITBUCKET_API_URL";
147
148    fn remote(&self) -> Remote {
149        self.remote.clone()
150    }
151
152    fn client(&self) -> ClientWithMiddleware {
153        self.client.clone()
154    }
155}
156
157impl BitbucketClient {
158    /// Constructs the URL for Bitbucket commits API.
159    fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
160        let mut url = format!(
161            "{}/{}/{}/commits?pagelen={MAX_PAGE_SIZE}&page={page}",
162            api_url, remote.owner, remote.repo
163        );
164
165        if let Some(ref_name) = ref_name {
166            url.push_str(&format!("&include={ref_name}"));
167        }
168
169        url
170    }
171
172    /// Constructs the URL for Bitbucket pull requests API.
173    fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
174        format!(
175            "{}/{}/{}/pullrequests?&pagelen={BITBUCKET_MAX_PAGE_PRS}&page={page}&state=MERGED",
176            api_url, remote.owner, remote.repo
177        )
178    }
179
180    /// Fetches the complete list of commits.
181    /// This is inefficient for large repositories; consider using
182    /// `get_commit_stream` instead.
183    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
184    pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
185        use futures::TryStreamExt;
186        crate::set_progress_message!("Fetching all commits from Bitbucket");
187        self.get_commit_stream(ref_name).try_collect().await
188    }
189
190    /// Fetches the complete list of pull requests.
191    /// This is inefficient for large repositories; consider using
192    /// `get_pull_request_stream` instead.
193    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
194    pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
195        use futures::TryStreamExt;
196        crate::set_progress_message!("Fetching all pull requests from Bitbucket");
197        self.get_pull_request_stream().try_collect().await
198    }
199
200    fn get_commit_stream(
201        &self,
202        ref_name: Option<&str>,
203    ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
204        let ref_name = ref_name.map(ToString::to_string);
205        async_stream! {
206            // The BitBucket API uses 1-based indexing for pages.
207            let page_stream = stream::iter(1..)
208                .map(|page| {
209                    let ref_name = ref_name.clone();
210                    async move {
211                        let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
212                        self.get_json::<BitbucketPagination<BitbucketCommit>>(&url).await
213                    }
214                })
215                .buffered(10);
216
217            let mut page_stream = Box::pin(page_stream);
218
219            while let Some(page_result) = page_stream.next().await {
220                match page_result {
221                    Ok(page) => {
222                        if page.values.is_empty() {
223                            break;
224                        }
225
226                        for commit in page.values {
227                            yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
228                        }
229                    }
230                    Err(e) => {
231                        yield Err(e);
232                        break;
233                    }
234                }
235            }
236        }
237    }
238
239    fn get_pull_request_stream(
240        &self,
241    ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
242        async_stream! {
243            // The BitBucket API uses 1-based indexing for pages.
244            let page_stream = stream::iter(1..)
245                .map(|page| async move {
246                    let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
247                    self.get_json::<BitbucketPagination<BitbucketPullRequest>>(&url).await
248                })
249                .buffered(5);
250
251            let mut page_stream = Box::pin(page_stream);
252
253            while let Some(page_result) = page_stream.next().await {
254                match page_result {
255                    Ok(page) => {
256                        if page.values.is_empty() {
257                            break;
258                        }
259
260                        for pr in page.values {
261                            yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
262                        }
263                    }
264                    Err(e) => {
265                        yield Err(e);
266                        break;
267                    }
268                }
269            }
270        }
271    }
272}
273
274#[cfg(test)]
275mod test {
276    use pretty_assertions::assert_eq;
277
278    use super::*;
279    use crate::remote::{RemoteCommit, RemotePullRequest};
280
281    #[test]
282    fn timestamp() {
283        let remote_commit = BitbucketCommit {
284            hash: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"),
285            author: Some(BitbucketCommitAuthor {
286                login: Some(String::from("orhun")),
287                nickname: None,
288            }),
289            date: String::from("2021-07-18T15:14:39+03:00"),
290        };
291
292        assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
293    }
294
295    #[test]
296    fn pull_request_author() {
297        let pull_request: BitbucketPullRequest = serde_json::from_str(
298            r#"{
299                "id": 42,
300                "title": "feat: add pr_author",
301                "merge_commit": { "hash": "1d244937ee6c" },
302                "author": { "nickname": "contributor" }
303            }"#,
304        )
305        .expect("failed to deserialize pull request");
306
307        assert_eq!(Some(String::from("contributor")), pull_request.author());
308    }
309
310    #[test]
311    fn pull_request_author_missing() {
312        let pull_request: BitbucketPullRequest = serde_json::from_str(
313            r#"{
314                "id": 42,
315                "title": "feat: add pr_author",
316                "merge_commit": { "hash": "1d244937ee6c" },
317                "author": {}
318            }"#,
319        )
320        .expect("failed to deserialize pull request");
321
322        assert_eq!(None, pull_request.author());
323    }
324}