gr_bin/vcs/
bitbucket.rs

1// Documentation: https://developer.atlassian.com/cloud/bitbucket/rest/intro/
2use 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                // Somewhat hacky, if the response is empty, return null
430                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        // TODO: make it work with user
617        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}