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] = &["bitbucket", "commit.bitbucket", "commit.remote"];
12
13pub(crate) const BITBUCKET_MAX_PAGE_PRS: usize = 50;
15
16#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct BitbucketCommit {
19 pub hash: String,
21 pub date: String,
23 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#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct BitbucketPagination<T> {
46 pub size: Option<i64>,
48 pub page: Option<i64>,
50 pub pagelen: Option<i64>,
53 pub next: Option<String>,
55 pub previous: Option<String>,
57 pub values: Vec<T>,
59}
60
61#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct BitbucketCommitAuthor {
67 #[serde(rename = "raw")]
69 pub login: Option<String>,
70 pub nickname: Option<String>,
72}
73
74#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct PullRequestLabel {
78 pub name: String,
80}
81
82#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct BitbucketPullRequestMergeCommit {
85 pub hash: String,
87}
88
89#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct BitbucketPullRequest {
92 pub id: i64,
94 pub title: Option<String>,
96 pub merge_commit: BitbucketPullRequestMergeCommit,
98 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#[derive(Debug, Clone)]
126pub struct BitbucketClient {
127 remote: Remote,
129 client: ClientWithMiddleware,
131}
132
133impl 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 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 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 #[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 #[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 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 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}