1use super::common::{
3 CreatePullRequest, CreateRepository, ForkRepository, ForkedFromRepository,
4 ListPullRequestFilters, PullRequest, PullRequestState, PullRequestStateFilter, Repository,
5 RepositoryVisibility, User, VersionControl, VersionControlSettings,
6};
7use eyre::{eyre, ContextCompat, Result};
8use native_tls::TlsConnector;
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use std::{fmt::Debug, sync::Arc};
11use time::OffsetDateTime;
12use tracing::{info, instrument, trace};
13use ureq::{Agent, AgentBuilder, Error};
14
15#[derive(Debug, Deserialize, Serialize)]
16pub enum BitbucketPullRequestState {
17 #[serde(rename = "OPEN")]
18 Open,
19 #[serde(rename = "DECLINED")]
20 Declined,
21 #[serde(rename = "MERGED")]
22 Merged,
23 #[serde(rename = "LOCKED")]
24 Locked,
25}
26
27impl From<BitbucketPullRequestState> for PullRequestState {
28 fn from(pr: BitbucketPullRequestState) -> PullRequestState {
29 match pr {
30 BitbucketPullRequestState::Open => PullRequestState::Open,
31 BitbucketPullRequestState::Declined => PullRequestState::Closed,
32 BitbucketPullRequestState::Merged => PullRequestState::Merged,
33 BitbucketPullRequestState::Locked => PullRequestState::Locked,
34 }
35 }
36}
37
38#[derive(Debug, Default, Deserialize, Serialize)]
39pub struct BitbucketUser {
40 pub uuid: String,
41 pub nickname: String,
42 pub display_name: String,
43}
44
45impl From<BitbucketUser> for User {
46 fn from(user: BitbucketUser) -> User {
47 let BitbucketUser { uuid, nickname, .. } = user;
48 User {
49 id: uuid,
50 username: nickname,
51 }
52 }
53}
54
55#[derive(Debug, Default, Deserialize, Serialize)]
56pub struct BitbucketTeam {
57 pub uuid: String,
58 pub username: String,
59 pub display_name: String,
60}
61
62impl From<BitbucketTeam> for User {
63 fn from(user: BitbucketTeam) -> User {
64 let BitbucketTeam { uuid, username, .. } = user;
65 User { id: uuid, username }
66 }
67}
68
69#[derive(Debug, Deserialize, Serialize)]
70pub struct BitbucketApproval {
71 approved: bool,
72 user: BitbucketUser,
73}
74
75#[derive(Debug, Deserialize, Serialize)]
76pub struct BitbucketMembership {
77 user: BitbucketUser,
78}
79
80#[derive(Debug, Deserialize, Serialize)]
81pub struct BitbucketCloneLink {
82 pub name: String,
83 pub href: String,
84}
85
86#[derive(Debug, Deserialize, Serialize)]
87pub struct BitbucketLink {
88 pub href: String,
89}
90
91#[derive(Debug, Deserialize, Serialize)]
92pub struct BitbucketForkedFromRepositoryLinks {
93 pub html: BitbucketLink,
94}
95
96#[derive(Debug, Deserialize, Serialize)]
97pub struct BitbucketRepositoryLinks {
98 pub html: BitbucketLink,
99 pub clone: Vec<BitbucketCloneLink>,
100}
101
102#[derive(Debug, Deserialize, Serialize)]
103pub struct BitbucketPullRequestLinks {
104 pub html: BitbucketLink,
105}
106
107#[derive(Debug, Deserialize, Serialize)]
108pub struct BitbucketCommit {
109 pub hash: String,
110}
111
112#[derive(Debug, Deserialize, Serialize)]
113pub struct BitbucketBranch {
114 pub name: String,
115}
116
117#[derive(Debug, Deserialize, Serialize)]
118pub struct BitbucketRevisionRepository {
119 pub full_name: String,
120}
121
122#[derive(Debug, Deserialize, Serialize)]
123pub struct BitbucketRevision {
124 pub branch: BitbucketBranch,
125 pub commit: BitbucketCommit,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub repository: Option<BitbucketRevisionRepository>,
128}
129
130#[derive(Debug, Deserialize, Serialize)]
131pub struct BitbucketCreateRevision {
132 pub branch: BitbucketBranch,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub repository: Option<BitbucketRevisionRepository>,
135}
136
137#[derive(Debug, Deserialize, Serialize)]
138pub struct BitbucketRepositoryProject {
139 uuid: String,
140 key: String,
141 name: String,
142}
143
144#[derive(Debug, Deserialize, Serialize)]
145pub struct BitbucketForkedFromRepository {
146 uuid: String,
147 name: String,
148 full_name: String,
149 links: BitbucketForkedFromRepositoryLinks,
150}
151
152#[derive(Debug, Deserialize, Serialize)]
153pub struct BitbucketRepository {
154 uuid: String,
155 name: String,
156 full_name: String,
157 links: BitbucketRepositoryLinks,
158 owner: BitbucketTeam,
159 description: String,
160 #[serde(with = "time::serde::iso8601")]
161 created_on: OffsetDateTime,
162 #[serde(with = "time::serde::iso8601")]
163 updated_on: OffsetDateTime,
164 language: String,
165 project: BitbucketRepositoryProject,
166 mainbranch: BitbucketBranch,
167 is_private: bool,
168 parent: Option<BitbucketForkedFromRepository>,
169}
170
171impl From<BitbucketRepository> for Repository {
172 fn from(repo: BitbucketRepository) -> Repository {
173 let BitbucketRepository {
174 name,
175 links,
176 full_name,
177 owner,
178 description,
179 created_on,
180 updated_on,
181 mainbranch,
182 is_private,
183 parent,
184 ..
185 } = repo;
186 let ssh_url = links
187 .clone
188 .iter()
189 .find(|BitbucketCloneLink { name, .. }| name == "ssh")
190 .map(|BitbucketCloneLink { href, .. }| href);
191 let https_url = links
192 .clone
193 .iter()
194 .find(|BitbucketCloneLink { name, .. }| name == "https")
195 .map(|BitbucketCloneLink { href, .. }| href);
196 Repository {
197 name,
198 full_name,
199 owner: Some(owner.into()),
200 html_url: links.html.href,
201 description,
202 created_at: created_on,
203 updated_at: updated_on,
204 visibility: if is_private {
205 RepositoryVisibility::Private
206 } else {
207 RepositoryVisibility::Public
208 },
209 archived: false,
210 default_branch: mainbranch.name,
211 forks_count: 0,
212 stars_count: 0,
213 ssh_url: ssh_url.unwrap().to_owned(),
214 https_url: https_url.unwrap().to_owned(),
215 forked_from: parent.map(|r| r.into()),
216 }
217 }
218}
219
220impl From<BitbucketForkedFromRepository> for ForkedFromRepository {
221 fn from(repo: BitbucketForkedFromRepository) -> ForkedFromRepository {
222 let BitbucketForkedFromRepository {
223 name,
224 links,
225 full_name,
226 ..
227 } = repo;
228 ForkedFromRepository {
229 name,
230 full_name,
231 html_url: links.html.href,
232 }
233 }
234}
235
236#[derive(Debug, Deserialize, Serialize)]
237struct BitbucketCreateRepository {
238 name: String,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 description: Option<String>,
241 is_private: bool,
242}
243
244#[derive(Debug, Deserialize, Serialize)]
245struct BitbucketForkRepositoryWorkspace {
246 slug: String,
247}
248
249#[derive(Debug, Deserialize, Serialize)]
250struct BitbucketForkRepository {
251 #[serde(skip_serializing_if = "Option::is_none")]
252 name: Option<String>,
253 #[serde(skip_serializing_if = "Option::is_none")]
254 workspace: Option<BitbucketForkRepositoryWorkspace>,
255}
256
257#[derive(Debug, Deserialize, Serialize)]
258pub struct BitbucketPullRequest {
259 pub id: u32,
260 pub state: BitbucketPullRequestState,
261 pub title: String,
262 pub description: String,
263 pub links: BitbucketPullRequestLinks,
264 #[serde(with = "time::serde::iso8601")]
265 pub created_on: OffsetDateTime,
266 #[serde(with = "time::serde::iso8601")]
267 pub updated_on: OffsetDateTime,
268 pub source: BitbucketRevision,
269 pub destination: BitbucketRevision,
270 pub author: BitbucketUser,
271 pub closed_by: Option<BitbucketUser>,
272 pub reviewers: Option<Vec<BitbucketUser>>,
273 pub close_source_branch: bool,
274}
275
276impl From<BitbucketPullRequest> for PullRequest {
277 fn from(pr: BitbucketPullRequest) -> PullRequest {
278 let BitbucketPullRequest {
279 id,
280 state,
281 title,
282 description,
283 source,
284 destination,
285 links,
286 created_on,
287 updated_on,
288 author,
289 closed_by,
290 reviewers,
291 close_source_branch,
292 } = pr;
293 PullRequest {
294 id,
295 state: state.into(),
296 title,
297 description,
298 source: source.branch.name,
299 source_sha: source.commit.hash,
300 target: destination.branch.name,
301 target_sha: destination.commit.hash,
302 url: links.html.href,
303 created_at: created_on,
304 updated_at: updated_on,
305 author: author.into(),
306 closed_by: closed_by.map(|u| u.into()),
307 reviewers: reviewers.map(|rs| rs.into_iter().map(|r| r.into()).collect()),
308 delete_source_branch: close_source_branch,
309 }
310 }
311}
312
313#[derive(Debug, Deserialize, Serialize)]
314pub struct BitbucketReviewer {
315 pub uuid: String,
316}
317
318#[derive(Debug, Deserialize, Serialize)]
319pub struct BitbucketCreatePullRequest {
320 pub title: String,
321 pub description: String,
322 pub source: BitbucketCreateRevision,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub destination: Option<BitbucketCreateRevision>,
325 pub close_source_branch: bool,
326 pub reviewers: Vec<BitbucketReviewer>,
327}
328
329#[derive(Debug, Deserialize, Serialize)]
330pub struct BitbucketMergePullRequest {
331 pub close_source_branch: bool,
332}
333
334impl From<CreatePullRequest> for BitbucketCreatePullRequest {
335 fn from(pr: CreatePullRequest) -> Self {
336 let CreatePullRequest {
337 title,
338 description,
339 source,
340 target: destination,
341 close_source_branch,
342 reviewers,
343 ..
344 } = pr;
345 Self {
346 title,
347 description,
348 source: BitbucketCreateRevision {
349 branch: BitbucketBranch { name: source },
350 repository: None,
351 },
352 destination: destination.map(|name| BitbucketCreateRevision {
353 branch: BitbucketBranch { name },
354 repository: None,
355 }),
356 close_source_branch,
357 reviewers: reviewers
358 .into_iter()
359 .map(|uuid| BitbucketReviewer { uuid })
360 .collect(),
361 }
362 }
363}
364
365#[derive(Debug, Deserialize, Serialize)]
366pub struct BitbucketPaginated<T> {
367 pub next: Option<String>,
368 pub page: u32,
369 pub pagelen: u32,
370 pub size: u32,
371 pub values: Vec<T>,
372}
373
374#[derive(Debug)]
375pub struct Bitbucket {
376 settings: VersionControlSettings,
377 client: Agent,
378 repo: String,
379}
380
381impl Bitbucket {
382 #[instrument(skip_all)]
383 fn get_repository_url(&self, url: &str) -> String {
384 format!("/repositories/{}{}", self.repo, url)
385 }
386
387 #[instrument(skip_all)]
388 fn call<T: DeserializeOwned, U: Serialize + Debug>(
389 &self,
390 method: &str,
391 url: &str,
392 body: Option<U>,
393 ) -> Result<T> {
394 let url = format!("https://api.bitbucket.org/2.0{url}");
395
396 info!("Calling with {method} {url}.");
397
398 let (username, password) = self
399 .settings
400 .auth
401 .split_once(':')
402 .wrap_err("Authentication has to contain a username and a token.")?;
403
404 trace!("Authenticating with username '{username}' and token '{password}'.");
405
406 let request = self.client.request(method, &url).set(
407 "Authorization",
408 &format!("Basic {}", base64::encode(format!("{username}:{password}"))),
409 );
410 let result = if let Some(body) = &body {
411 trace!("Sending body: {}.", serde_json::to_string(&body)?);
412 request.send_json(body)
413 } else {
414 request.call()
415 };
416
417 match result {
418 Ok(result) => {
419 let status = result.status();
420 let mut t = result.into_string()?;
421
422 info!(
423 "Received response with response code {} with body size {}.",
424 status,
425 t.len()
426 );
427 trace!("Response body: {t}.");
428
429 if t.is_empty() {
431 t = "null".to_string();
432 }
433
434 let t: T = serde_json::from_str(&t)?;
435 Ok(t)
436 }
437 Err(Error::Status(status, result)) => {
438 let t = result.into_string()?;
439
440 info!(
441 "Received response with response code {} with body size {}.",
442 status,
443 t.len()
444 );
445 Err(eyre!("Request failed (response: {}).", t))
446 }
447 Err(Error::Transport(_)) => Err(eyre!("Sending data failed.")),
448 }
449 }
450
451 #[instrument(skip(self))]
452 fn call_paginated<T: DeserializeOwned>(&self, url: &str, params: &str) -> Result<Vec<T>> {
453 let mut collected_values: Vec<T> = vec![];
454 let mut i = 1;
455 loop {
456 info!("Reading page {}.", i);
457
458 let mut page: BitbucketPaginated<T> = self.call(
459 "GET",
460 &format!("{url}?page={i}{params}"),
461 None as Option<i32>,
462 )?;
463
464 collected_values.append(&mut page.values);
465
466 if page.next.is_none() {
467 break;
468 }
469
470 i += 1;
471 }
472 Ok(collected_values)
473 }
474
475 #[instrument(skip(self))]
476 fn get_workspace_users(&self, usernames: Vec<String>) -> Result<Vec<BitbucketUser>> {
477 let (workspace, _) = self
478 .repo
479 .split_once('/')
480 .wrap_err(eyre!("Repo URL is malformed: {}", &self.repo))?;
481 let members: Vec<BitbucketMembership> =
482 self.call_paginated(&format!("/workspaces/{workspace}/members"), "")?;
483
484 Ok(members
485 .into_iter()
486 .map(|m| m.user)
487 .filter(|u| usernames.contains(&u.nickname))
488 .collect())
489 }
490}
491
492impl VersionControl for Bitbucket {
493 #[instrument(skip_all)]
494 fn init(_: String, repo: String, settings: VersionControlSettings) -> Self {
495 let client = AgentBuilder::new()
496 .tls_connector(Arc::new(TlsConnector::new().unwrap()))
497 .build();
498 Bitbucket {
499 settings,
500 client,
501 repo,
502 }
503 }
504 #[instrument(skip_all)]
505 fn login_url(&self) -> String {
506 "https://bitbucket.org/account/settings/app-passwords/new".to_string()
507 }
508 #[instrument(skip_all)]
509 fn validate_token(&self, token: &str) -> Result<()> {
510 if !token.contains(':') {
511 Err(eyre!("Enter your Bitbucket username and the token, separated with a colon (user:ABBT...)."))
512 } else {
513 Ok(())
514 }
515 }
516 #[instrument(skip(self))]
517 fn create_pr(&self, mut pr: CreatePullRequest) -> Result<PullRequest> {
518 let reviewers = self.get_workspace_users(pr.reviewers.clone())?;
519 pr.reviewers = reviewers.into_iter().map(|r| r.uuid).collect();
520
521 let mut url = self.get_repository_url("/pullrequests");
522 let mut bitbucket_pr = BitbucketCreatePullRequest::from(pr);
523 if self.settings.fork {
524 let repo = self.get_repository()?;
525 if let Some(forked) = repo.forked_from {
526 url = format!("/repositories/{}/pullrequests", forked.full_name);
527 bitbucket_pr.source = BitbucketCreateRevision {
528 repository: Some(BitbucketRevisionRepository {
529 full_name: repo.full_name,
530 }),
531 ..bitbucket_pr.source
532 }
533 }
534 }
535
536 let new_pr: BitbucketPullRequest = self.call("POST", &url, Some(bitbucket_pr))?;
537
538 Ok(new_pr.into())
539 }
540 #[instrument(skip(self))]
541 fn get_pr_by_id(&self, id: u32) -> Result<PullRequest> {
542 let pr: BitbucketPullRequest = self.call(
543 "GET",
544 &self.get_repository_url(&format!("/pullrequests/{id}")),
545 None as Option<u32>,
546 )?;
547
548 Ok(pr.into())
549 }
550 #[instrument(skip(self))]
551 fn get_pr_by_branch(&self, branch: &str) -> Result<PullRequest> {
552 let prs: Vec<BitbucketPullRequest> =
553 self.call_paginated(&self.get_repository_url("/pullrequests"), "")?;
554
555 prs.into_iter()
556 .find(|pr| pr.source.branch.name == branch)
557 .map(|pr| pr.into())
558 .wrap_err(eyre!("Pull request on branch {branch} not found."))
559 }
560 #[instrument(skip(self))]
561 fn list_prs(&self, filters: ListPullRequestFilters) -> Result<Vec<PullRequest>> {
562 let state_param = match filters.state {
563 PullRequestStateFilter::Open => "&state=OPEN",
564 PullRequestStateFilter::Closed => "&state=DECLINED",
565 PullRequestStateFilter::Merged => "&state=MERGED",
566 PullRequestStateFilter::Locked | PullRequestStateFilter::All => "",
567 };
568 let prs: Vec<BitbucketPullRequest> =
569 self.call_paginated(&self.get_repository_url("/pullrequests"), state_param)?;
570
571 Ok(prs.into_iter().map(|pr| pr.into()).collect())
572 }
573 #[instrument(skip(self))]
574 fn approve_pr(&self, id: u32) -> Result<()> {
575 let _: BitbucketApproval = self.call(
576 "POST",
577 &self.get_repository_url(&format!("/pullrequests/{id}/approve")),
578 None as Option<i32>,
579 )?;
580
581 Ok(())
582 }
583 #[instrument(skip(self))]
584 fn close_pr(&self, id: u32) -> Result<PullRequest> {
585 let pr: BitbucketPullRequest = self.call(
586 "POST",
587 &self.get_repository_url(&format!("/pullrequests/{id}/decline")),
588 None as Option<i32>,
589 )?;
590
591 Ok(pr.into())
592 }
593 #[instrument(skip(self))]
594 fn merge_pr(&self, id: u32, close_source_branch: bool) -> Result<PullRequest> {
595 let pr: BitbucketPullRequest = self.call(
596 "POST",
597 &self.get_repository_url(&format!("/pullrequests/{id}/merge")),
598 Some(BitbucketMergePullRequest {
599 close_source_branch,
600 }),
601 )?;
602
603 Ok(pr.into())
604 }
605
606 #[instrument(skip_all)]
607 fn get_repository(&self) -> Result<Repository> {
608 let repo =
609 self.call::<BitbucketRepository, i32>("GET", &self.get_repository_url(""), None)?;
610
611 Ok(repo.into())
612 }
613
614 #[instrument(skip_all)]
615 fn create_repository(&self, repo: CreateRepository) -> Result<Repository> {
616 let CreateRepository {
618 name,
619 organization,
620 visibility,
621 description,
622 ..
623 } = repo;
624 let (user, _) = self
625 .settings
626 .auth
627 .split_once(':')
628 .wrap_err("Authentication format is invalid")?;
629 let workspace = organization.unwrap_or(user.to_string());
630 let create_repo: BitbucketCreateRepository = BitbucketCreateRepository {
631 name: name.clone(),
632 description,
633 is_private: visibility != RepositoryVisibility::Public,
634 };
635 let new_repo: BitbucketRepository = self.call(
636 "POST",
637 &format!("/repositories/{workspace}/{}", name),
638 Some(create_repo),
639 )?;
640
641 Ok(new_repo.into())
642 }
643
644 #[instrument(skip_all)]
645 fn fork_repository(&self, repo: ForkRepository) -> Result<Repository> {
646 let ForkRepository { name, organization } = repo;
647 let workspace = organization.map(|slug| BitbucketForkRepositoryWorkspace { slug });
648
649 let new_repo: BitbucketRepository = self.call(
650 "POST",
651 &self.get_repository_url("/forks"),
652 Some(BitbucketForkRepository { name, workspace }),
653 )?;
654
655 Ok(new_repo.into())
656 }
657
658 #[instrument(skip_all)]
659 fn delete_repository(&self) -> Result<()> {
660 self.call("DELETE", &self.get_repository_url(""), None as Option<i32>)?;
661
662 Ok(())
663 }
664}