gr_bin/vcs/
gitlab.rs

1// Documentation: https://docs.gitlab.com/ee/api/api_resources.html
2use super::common::{
3    CreatePullRequest, CreateRepository, ForkRepository, ForkedFromRepository,
4    ListPullRequestFilters, PullRequest, PullRequestState, PullRequestStateFilter,
5    PullRequestUserFilter, Repository, RepositoryVisibility, User, VersionControl,
6    VersionControlSettings,
7};
8use eyre::{eyre, Result};
9use native_tls::TlsConnector;
10use serde::{de::DeserializeOwned, Deserialize, Serialize};
11use std::{fmt::Debug, sync::Arc};
12use time::OffsetDateTime;
13use tracing::{info, instrument, trace};
14use ureq::{Agent, AgentBuilder, Error};
15use urlencoding::encode;
16
17#[derive(Debug, Deserialize, Serialize)]
18pub struct GitLabUser {
19    pub id: u32,
20    pub username: String,
21    pub name: String,
22}
23
24impl From<GitLabUser> for User {
25    fn from(user: GitLabUser) -> User {
26        let GitLabUser { id, username, .. } = user;
27        User {
28            id: id.to_string(),
29            username,
30        }
31    }
32}
33
34#[derive(Debug, Deserialize, Serialize)]
35pub struct GitLabApprovalUser {
36    user: GitLabUser,
37}
38
39#[derive(Debug, Deserialize, Serialize)]
40pub struct GitLabApproval {
41    approved: bool,
42    approved_by: Vec<GitLabApprovalUser>,
43}
44
45#[derive(Debug, Deserialize, Serialize, PartialEq)]
46pub struct GitLabNamespace {
47    id: u32,
48    name: String,
49    web_url: String,
50}
51
52#[derive(Debug, Deserialize, Serialize, PartialEq)]
53pub enum GitLabRepositoryVisibility {
54    #[serde(rename = "public")]
55    Public,
56    #[serde(rename = "internal")]
57    Internal,
58    #[serde(rename = "private")]
59    Private,
60}
61
62#[derive(Debug, Deserialize, Serialize)]
63struct GitLabForkedFromRepository {
64    id: u32,
65    name: String,
66    name_with_namespace: String,
67    path: String,
68    path_with_namespace: String,
69    web_url: String,
70}
71
72#[derive(Debug, Deserialize, Serialize)]
73struct GitLabRepository {
74    id: u32,
75    name: String,
76    name_with_namespace: String,
77    path: String,
78    path_with_namespace: String,
79    description: Option<String>,
80    #[serde(with = "time::serde::iso8601")]
81    created_at: OffsetDateTime,
82    #[serde(with = "time::serde::iso8601")]
83    last_activity_at: OffsetDateTime,
84    default_branch: String,
85    web_url: String,
86    ssh_url_to_repo: String,
87    http_url_to_repo: String,
88    forks_count: u32,
89    star_count: u32,
90    archived: bool,
91    visibility: GitLabRepositoryVisibility,
92    owner: Option<GitLabUser>,
93    forked_from_project: Option<GitLabForkedFromRepository>,
94}
95
96impl From<GitLabRepository> for Repository {
97    fn from(repo: GitLabRepository) -> Repository {
98        let GitLabRepository {
99            name,
100            path_with_namespace,
101            description,
102            created_at,
103            default_branch,
104            web_url,
105            ssh_url_to_repo,
106            http_url_to_repo,
107            forks_count,
108            star_count,
109            last_activity_at,
110            archived,
111            visibility,
112            owner,
113            forked_from_project,
114            ..
115        } = repo;
116        Repository {
117            name,
118            full_name: path_with_namespace,
119            owner: owner.map(|o| o.into()),
120            visibility: match visibility {
121                GitLabRepositoryVisibility::Public => RepositoryVisibility::Public,
122                GitLabRepositoryVisibility::Internal => RepositoryVisibility::Internal,
123                GitLabRepositoryVisibility::Private => RepositoryVisibility::Private,
124            },
125            html_url: web_url,
126            description: description.unwrap_or_default(),
127            created_at,
128            updated_at: last_activity_at,
129            archived,
130            default_branch,
131            forks_count,
132            stars_count: star_count,
133            ssh_url: ssh_url_to_repo,
134            https_url: http_url_to_repo,
135            forked_from: forked_from_project.map(ForkedFromRepository::from),
136        }
137    }
138}
139
140impl From<GitLabForkedFromRepository> for ForkedFromRepository {
141    fn from(repo: GitLabForkedFromRepository) -> ForkedFromRepository {
142        let GitLabForkedFromRepository {
143            name,
144            path_with_namespace,
145            web_url,
146            ..
147        } = repo;
148        ForkedFromRepository {
149            name,
150            full_name: path_with_namespace,
151            html_url: web_url,
152        }
153    }
154}
155
156#[derive(Debug, Deserialize, Serialize)]
157struct GitLabCreateRepository {
158    name: String,
159    path: String,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    description: Option<String>,
162    namespace_id: Option<u32>,
163    visibility: GitLabRepositoryVisibility,
164    initialize_with_readme: bool,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    default_branch: Option<String>,
167}
168
169#[derive(Debug, Deserialize, Serialize)]
170struct GitLabForkRepository {
171    #[serde(skip_serializing_if = "Option::is_none")]
172    name: Option<String>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    namespace_path: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    path: Option<String>,
177}
178
179#[derive(Debug, Deserialize, Serialize)]
180struct GitLabRepositoryDeleted {
181    message: String,
182}
183
184#[derive(Debug, Deserialize, Serialize)]
185pub struct GitLabDiffRefs {
186    base_sha: String,
187    head_sha: String,
188    start_sha: String,
189}
190
191#[derive(Debug, Deserialize, Serialize)]
192pub enum GitLabPullRequestState {
193    #[serde(rename = "opened")]
194    Open,
195    #[serde(rename = "closed")]
196    Closed,
197    #[serde(rename = "merged")]
198    Merged,
199    #[serde(rename = "locked")]
200    Locked,
201}
202
203impl From<GitLabPullRequestState> for PullRequestState {
204    fn from(state: GitLabPullRequestState) -> PullRequestState {
205        match state {
206            GitLabPullRequestState::Open => PullRequestState::Open,
207            GitLabPullRequestState::Closed => PullRequestState::Closed,
208            GitLabPullRequestState::Merged => PullRequestState::Merged,
209            GitLabPullRequestState::Locked => PullRequestState::Locked,
210        }
211    }
212}
213
214#[derive(Debug, Deserialize, Serialize)]
215pub struct GitLabPullRequest {
216    pub id: u32,
217    pub iid: u32,
218    pub state: GitLabPullRequestState,
219    pub title: String,
220    pub description: String,
221    pub source_branch: String,
222    pub target_branch: String,
223    pub web_url: String,
224    #[serde(with = "time::serde::iso8601")]
225    pub created_at: OffsetDateTime,
226    #[serde(with = "time::serde::iso8601")]
227    pub updated_at: OffsetDateTime,
228    pub author: GitLabUser,
229    pub closed_by: Option<GitLabUser>,
230    pub reviewers: Option<Vec<GitLabUser>>,
231    pub sha: String,
232    pub diff_refs: Option<GitLabDiffRefs>,
233    pub should_remove_source_branch: Option<bool>,
234    pub force_remove_source_branch: bool,
235}
236
237impl From<GitLabPullRequest> for PullRequest {
238    fn from(pr: GitLabPullRequest) -> PullRequest {
239        let GitLabPullRequest {
240            iid,
241            state,
242            title,
243            description,
244            source_branch,
245            target_branch,
246            web_url,
247            created_at,
248            updated_at,
249            author,
250            closed_by,
251            reviewers,
252            diff_refs,
253            sha,
254            should_remove_source_branch,
255            force_remove_source_branch,
256            ..
257        } = pr;
258        let diff_refs = diff_refs.unwrap_or(GitLabDiffRefs {
259            head_sha: sha,
260            // TODO: if we don't have diff_refs, we cannot use the target_sha
261            base_sha: String::new(),
262            start_sha: String::new(),
263        });
264        PullRequest {
265            id: iid,
266            state: state.into(),
267            title,
268            description,
269            source: source_branch,
270            source_sha: diff_refs.head_sha,
271            target: target_branch,
272            target_sha: diff_refs.base_sha,
273            url: web_url,
274            created_at,
275            updated_at,
276            author: author.into(),
277            closed_by: closed_by.map(|c| c.into()),
278            reviewers: reviewers.map(|rs| rs.into_iter().map(|r| r.into()).collect()),
279            delete_source_branch: should_remove_source_branch.unwrap_or_default()
280                || force_remove_source_branch,
281        }
282    }
283}
284
285#[derive(Debug, Deserialize, Serialize)]
286pub struct GitLabCreatePullRequest {
287    pub title: String,
288    pub description: String,
289    pub source_branch: String,
290    pub target_branch: String,
291    pub remove_source_branch: bool,
292    pub reviewer_ids: Vec<String>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub target_project_id: Option<u32>,
295}
296
297impl From<CreatePullRequest> for GitLabCreatePullRequest {
298    fn from(pr: CreatePullRequest) -> Self {
299        let CreatePullRequest {
300            title,
301            description,
302            source,
303            target,
304            close_source_branch,
305            reviewers,
306        } = pr;
307        Self {
308            title,
309            description,
310            source_branch: source,
311            // We are never supposed to fallback to this, but handle it
312            target_branch: target.unwrap_or("master".to_string()),
313            remove_source_branch: close_source_branch,
314            reviewer_ids: reviewers,
315            target_project_id: None,
316        }
317    }
318}
319
320#[derive(Debug, Default, Deserialize, Serialize)]
321pub enum GitLabUpdatePullRequestStateEvent {
322    #[serde(rename = "reopen")]
323    Reopen,
324    #[default]
325    #[serde(rename = "close")]
326    Close,
327}
328
329#[derive(Debug, Default, Deserialize, Serialize)]
330pub struct GitLabUpdatePullRequest {
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub title: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub description: Option<String>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub source_branch: Option<String>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub target_branch: Option<String>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub remove_source_branch: Option<bool>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub state_event: Option<GitLabUpdatePullRequestStateEvent>,
343}
344
345#[derive(Debug, Deserialize, Serialize)]
346pub struct GitLabMergePullRequest {
347    pub should_remove_source_branch: bool,
348}
349
350#[derive(Debug)]
351pub struct GitLab {
352    settings: VersionControlSettings,
353    client: Agent,
354    hostname: String,
355    repo: String,
356}
357
358impl GitLab {
359    #[instrument(skip_all)]
360    fn get_repository_url(&self, url: &str) -> String {
361        format!("/projects/{}{}", encode(&self.repo), url)
362    }
363
364    #[instrument(skip_all)]
365    fn call<T: DeserializeOwned, U: Serialize + Debug>(
366        &self,
367        method: &str,
368        url: &str,
369        body: Option<U>,
370    ) -> Result<T> {
371        let url = format!("https://{}/api/v4{}", self.hostname, url);
372
373        info!("Calling with {method} on {url}.");
374
375        let token = &self.settings.auth;
376
377        trace!("Authenticating with token '{token}'.");
378
379        let request = self
380            .client
381            .request(method, &url)
382            .set("Authorization", &format!("Bearer {}", token))
383            .set("Content-Type", "application/json");
384        let result = if let Some(body) = &body {
385            trace!("Sending body: {}.", serde_json::to_string(&body)?);
386            request.send_json(body)
387        } else {
388            request.call()
389        };
390
391        match result {
392            Ok(result) => {
393                let status = result.status();
394                let t = result.into_string()?;
395
396                info!(
397                    "Received response with response code {} with body size {}.",
398                    status,
399                    t.len()
400                );
401                trace!("Response body: {t}.");
402                let t: T = serde_json::from_str(&t)?;
403                Ok(t)
404            }
405            Err(Error::Status(status, result)) => {
406                let t = result.into_string()?;
407
408                info!(
409                    "Received response with response code {} with body size {}.",
410                    status,
411                    t.len()
412                );
413                Err(eyre!("Request failed (response: {}).", t))
414            }
415            Err(Error::Transport(_)) => Err(eyre!("Sending data failed.")),
416        }
417    }
418
419    #[instrument(skip_all)]
420    fn get_repository_data(&self) -> Result<GitLabRepository> {
421        self.call::<GitLabRepository, i32>("GET", &self.get_repository_url(""), None)
422    }
423
424    #[instrument(skip(self))]
425    fn get_user_by_name(&self, username: &str) -> Result<User> {
426        let users: Vec<GitLabUser> = self.call(
427            "GET",
428            &format!("/users?username={username}"),
429            None as Option<i32>,
430        )?;
431
432        match users.into_iter().next() {
433            Some(user) => Ok(user.into()),
434            None => Err(eyre!("User with name {username} not found.")),
435        }
436    }
437}
438
439impl VersionControl for GitLab {
440    #[instrument(skip_all)]
441    fn init(hostname: String, repo: String, settings: VersionControlSettings) -> Self {
442        let client = AgentBuilder::new()
443            .tls_connector(Arc::new(TlsConnector::new().unwrap()))
444            .build();
445        GitLab {
446            settings,
447            client,
448            hostname,
449            repo,
450        }
451    }
452    #[instrument(skip_all)]
453    fn login_url(&self) -> String {
454        format!(
455            "https://{}/-/profile/personal_access_tokens?name=gr&scopes=read_user,api",
456            self.hostname
457        )
458    }
459    #[instrument(skip_all)]
460    fn validate_token(&self, token: &str) -> Result<()> {
461        if !token.starts_with("glpat-") {
462            Err(eyre!("Your GitLab token has to start with 'glpat'."))
463        } else if token.len() != 26 {
464            Err(eyre!("Your GitLab token has to be 26 characters long."))
465        } else {
466            Ok(())
467        }
468    }
469    #[instrument(skip(self))]
470    fn create_pr(&self, mut pr: CreatePullRequest) -> Result<PullRequest> {
471        let reviewers = pr
472            .reviewers
473            .iter()
474            .map(|reviewer| self.get_user_by_name(reviewer))
475            .collect::<Result<Vec<User>>>()?;
476
477        pr.reviewers = reviewers.into_iter().map(|r| r.id).collect();
478
479        pr.target = pr.target.or(self.settings.default_branch.clone());
480        if pr.target.is_none() {
481            let GitLabRepository { default_branch, .. } = self.get_repository_data()?;
482            info!("Using {default_branch} as target branch.");
483            pr.target = Some(default_branch);
484        }
485
486        let mut gitlab_pr = GitLabCreatePullRequest::from(pr);
487        if self.settings.fork {
488            let repo = self.get_repository_data()?;
489            if let Some(forked) = repo.forked_from_project {
490                gitlab_pr.target_project_id = Some(forked.id);
491            }
492        };
493
494        let new_pr: GitLabPullRequest = self.call(
495            "POST",
496            &self.get_repository_url("/merge_requests"),
497            Some(gitlab_pr),
498        )?;
499
500        Ok(new_pr.into())
501    }
502    #[instrument(skip(self))]
503    fn get_pr_by_id(&self, id: u32) -> Result<PullRequest> {
504        let pr: GitLabPullRequest = self.call(
505            "GET",
506            &self.get_repository_url(&format!("/merge_requests/{id}")),
507            None as Option<i32>,
508        )?;
509
510        Ok(pr.into())
511    }
512    #[instrument(skip(self))]
513    fn get_pr_by_branch(&self, branch: &str) -> Result<PullRequest> {
514        let prs: Vec<GitLabPullRequest> = self.call(
515            "GET",
516            &self.get_repository_url(&format!("/merge_requests?source_branch={branch}")),
517            None as Option<i32>,
518        )?;
519
520        match prs.into_iter().next() {
521            Some(pr) => Ok(pr.into()),
522            None => Err(eyre!("Pull request on branch {branch} not found.")),
523        }
524    }
525    #[instrument(skip(self))]
526    fn list_prs(&self, filters: ListPullRequestFilters) -> Result<Vec<PullRequest>> {
527        let scope_param = match filters.author {
528            PullRequestUserFilter::All => "?scope=all",
529            PullRequestUserFilter::Me => "?scope=created_by_me",
530        };
531        let state_param = match filters.state {
532            PullRequestStateFilter::Open => "&state=opened",
533            PullRequestStateFilter::Closed => "&state=closed",
534            PullRequestStateFilter::Merged => "&state=merged",
535            PullRequestStateFilter::Locked => "&state=locked",
536            PullRequestStateFilter::All => "",
537        };
538        let prs: Vec<GitLabPullRequest> = self.call(
539            "GET",
540            &self.get_repository_url(&format!("/merge_requests{scope_param}{state_param}")),
541            None as Option<i32>,
542        )?;
543
544        Ok(prs.into_iter().map(|pr| pr.into()).collect())
545    }
546    #[instrument(skip(self))]
547    fn approve_pr(&self, id: u32) -> Result<()> {
548        let _: GitLabApproval = self.call(
549            "POST",
550            &self.get_repository_url(&format!("/merge_requests/{id}/approve")),
551            None as Option<i32>,
552        )?;
553
554        Ok(())
555    }
556    #[instrument(skip(self))]
557    fn close_pr(&self, id: u32) -> Result<PullRequest> {
558        let closing = GitLabUpdatePullRequest {
559            state_event: Some(GitLabUpdatePullRequestStateEvent::Close),
560            ..GitLabUpdatePullRequest::default()
561        };
562        let pr: GitLabPullRequest = self.call(
563            "PUT",
564            &self.get_repository_url(&format!("/merge_requests/{id}")),
565            Some(closing),
566        )?;
567
568        Ok(pr.into())
569    }
570    #[instrument(skip(self))]
571    fn merge_pr(&self, id: u32, should_remove_source_branch: bool) -> Result<PullRequest> {
572        let pr: GitLabPullRequest = self.call(
573            "PUT",
574            &self.get_repository_url(&format!("/merge_requests/{id}/merge")),
575            Some(GitLabMergePullRequest {
576                should_remove_source_branch,
577            }),
578        )?;
579
580        Ok(pr.into())
581    }
582
583    #[instrument(skip_all)]
584    fn get_repository(&self) -> Result<Repository> {
585        let repo = self.get_repository_data()?;
586
587        Ok(repo.into())
588    }
589    #[instrument(skip_all)]
590    fn create_repository(&self, repo: CreateRepository) -> Result<Repository> {
591        let CreateRepository {
592            name,
593            organization,
594            description,
595            visibility,
596            init,
597            default_branch,
598            gitignore: _,
599            license: _,
600        } = repo;
601
602        let namespace_id = organization.and_then(|org| {
603            self.call::<GitLabNamespace, Option<u32>>(
604                "POST",
605                &format!("/namespaces?search={org}"),
606                None,
607            )
608            .map(|ns| ns.id)
609            .ok()
610        });
611
612        let create_repo = GitLabCreateRepository {
613            path: name.clone(),
614            name,
615            description,
616            namespace_id,
617            initialize_with_readme: init,
618            visibility: match visibility {
619                RepositoryVisibility::Public => GitLabRepositoryVisibility::Public,
620                RepositoryVisibility::Internal => GitLabRepositoryVisibility::Internal,
621                RepositoryVisibility::Private => GitLabRepositoryVisibility::Private,
622            },
623            default_branch,
624        };
625
626        let new_repo: GitLabRepository = self.call("POST", "/projects", Some(create_repo))?;
627
628        Ok(new_repo.into())
629    }
630    #[instrument(skip_all)]
631    fn fork_repository(&self, repo: ForkRepository) -> Result<Repository> {
632        let ForkRepository {
633            name,
634            organization: namespace_path,
635        } = repo;
636        let path = match (&namespace_path, &name) {
637            (Some(ns), Some(n)) => Some(format!("{ns}/{n}")),
638            (None, Some(n)) => Some(n.to_string()),
639            _ => None,
640        };
641
642        let new_repo: GitLabRepository = self.call(
643            "POST",
644            &self.get_repository_url("/fork"),
645            Some(GitLabForkRepository {
646                name,
647                namespace_path,
648                path,
649            }),
650        )?;
651
652        Ok(new_repo.into())
653    }
654
655    #[instrument(skip_all)]
656    fn delete_repository(&self) -> Result<()> {
657        let _: GitLabRepositoryDeleted =
658            self.call("DELETE", &self.get_repository_url(""), None as Option<i32>)?;
659
660        Ok(())
661    }
662}