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 the commit.
62#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct BitbucketCommitAuthor {
64    /// Username.
65    #[serde(rename = "raw")]
66    pub login: Option<String>,
67}
68
69/// Label of the pull request.
70#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct PullRequestLabel {
73    /// Name of the label.
74    pub name: String,
75}
76
77/// Representation of a single pull request's merge commit
78#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct BitbucketPullRequestMergeCommit {
80    /// SHA of the merge commit.
81    pub hash: String,
82}
83
84/// Representation of a single pull request.
85#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct BitbucketPullRequest {
87    /// Pull request number.
88    pub id: i64,
89    /// Pull request title.
90    pub title: Option<String>,
91    /// Bitbucket Pull Request Merge Commit
92    pub merge_commit: BitbucketPullRequestMergeCommit,
93    /// Author of Pull Request
94    pub author: BitbucketCommitAuthor,
95}
96
97impl RemotePullRequest for BitbucketPullRequest {
98    fn number(&self) -> i64 {
99        self.id
100    }
101
102    fn title(&self) -> Option<String> {
103        self.title.clone()
104    }
105
106    fn labels(&self) -> Vec<String> {
107        vec![]
108    }
109
110    fn merge_commit(&self) -> Option<String> {
111        Some(self.merge_commit.hash.clone())
112    }
113}
114
115/// HTTP client for handling Bitbucket REST API requests.
116#[derive(Debug, Clone)]
117pub struct BitbucketClient {
118    /// Remote.
119    remote: Remote,
120    /// HTTP client.
121    client: ClientWithMiddleware,
122}
123
124/// Constructs a Bitbucket client from the remote configuration.
125impl TryFrom<Remote> for BitbucketClient {
126    type Error = Error;
127    fn try_from(remote: Remote) -> Result<Self> {
128        Ok(Self {
129            client: remote.create_client("application/json")?,
130            remote,
131        })
132    }
133}
134
135impl RemoteClient for BitbucketClient {
136    const API_URL: &'static str = "https://api.bitbucket.org/2.0/repositories";
137    const API_URL_ENV: &'static str = "BITBUCKET_API_URL";
138
139    fn remote(&self) -> Remote {
140        self.remote.clone()
141    }
142
143    fn client(&self) -> ClientWithMiddleware {
144        self.client.clone()
145    }
146}
147
148impl BitbucketClient {
149    /// Constructs the URL for Bitbucket commits API.
150    fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
151        let mut url = format!(
152            "{}/{}/{}/commits?pagelen={MAX_PAGE_SIZE}&page={page}",
153            api_url, remote.owner, remote.repo
154        );
155
156        if let Some(ref_name) = ref_name {
157            url.push_str(&format!("&include={ref_name}"));
158        }
159
160        url
161    }
162
163    /// Constructs the URL for Bitbucket pull requests API.
164    fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
165        format!(
166            "{}/{}/{}/pullrequests?&pagelen={BITBUCKET_MAX_PAGE_PRS}&page={page}&state=MERGED",
167            api_url, remote.owner, remote.repo
168        )
169    }
170
171    /// Fetches the complete list of commits.
172    /// This is inefficient for large repositories; consider using
173    /// `get_commit_stream` instead.
174    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
175    pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
176        use futures::TryStreamExt;
177        crate::set_progress_message!("Fetching all commits from Bitbucket");
178        self.get_commit_stream(ref_name).try_collect().await
179    }
180
181    /// Fetches the complete list of pull requests.
182    /// This is inefficient for large repositories; consider using
183    /// `get_pull_request_stream` instead.
184    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
185    pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
186        use futures::TryStreamExt;
187        crate::set_progress_message!("Fetching all pull requests from Bitbucket");
188        self.get_pull_request_stream().try_collect().await
189    }
190
191    fn get_commit_stream(
192        &self,
193        ref_name: Option<&str>,
194    ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
195        let ref_name = ref_name.map(ToString::to_string);
196        async_stream! {
197            // The BitBucket API uses 1-based indexing for pages.
198            let page_stream = stream::iter(1..)
199                .map(|page| {
200                    let ref_name = ref_name.clone();
201                    async move {
202                        let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
203                        self.get_json::<BitbucketPagination<BitbucketCommit>>(&url).await
204                    }
205                })
206                .buffered(10);
207
208            let mut page_stream = Box::pin(page_stream);
209
210            while let Some(page_result) = page_stream.next().await {
211                match page_result {
212                    Ok(page) => {
213                        if page.values.is_empty() {
214                            break;
215                        }
216
217                        for commit in page.values {
218                            yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
219                        }
220                    }
221                    Err(e) => {
222                        yield Err(e);
223                        break;
224                    }
225                }
226            }
227        }
228    }
229
230    fn get_pull_request_stream(
231        &self,
232    ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
233        async_stream! {
234            // The BitBucket API uses 1-based indexing for pages.
235            let page_stream = stream::iter(1..)
236                .map(|page| async move {
237                    let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
238                    self.get_json::<BitbucketPagination<BitbucketPullRequest>>(&url).await
239                })
240                .buffered(5);
241
242            let mut page_stream = Box::pin(page_stream);
243
244            while let Some(page_result) = page_stream.next().await {
245                match page_result {
246                    Ok(page) => {
247                        if page.values.is_empty() {
248                            break;
249                        }
250
251                        for pr in page.values {
252                            yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
253                        }
254                    }
255                    Err(e) => {
256                        yield Err(e);
257                        break;
258                    }
259                }
260            }
261        }
262    }
263}
264
265#[cfg(test)]
266mod test {
267    use pretty_assertions::assert_eq;
268
269    use super::*;
270    use crate::remote::RemoteCommit;
271
272    #[test]
273    fn timestamp() {
274        let remote_commit = BitbucketCommit {
275            hash: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"),
276            author: Some(BitbucketCommitAuthor {
277                login: Some(String::from("orhun")),
278            }),
279            date: String::from("2021-07-18T15:14:39+03:00"),
280        };
281
282        assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
283    }
284}