gr_bin/vcs/
gitea.rs

1// Documentation: https://codeberg.org/api/swagger
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 struct GiteaUser {
17    pub id: u32,
18    pub login: String,
19}
20
21impl From<GiteaUser> for User {
22    fn from(user: GiteaUser) -> User {
23        let GiteaUser { id, login, .. } = user;
24        User {
25            id: id.to_string(),
26            username: login,
27        }
28    }
29}
30
31#[derive(Debug, Deserialize, Serialize)]
32pub struct GiteaPullRequestReview {
33    pub id: u32,
34    pub body: u32,
35    pub user: GiteaUser,
36}
37
38#[derive(Debug, Deserialize, Serialize)]
39struct GiteaRepository {
40    name: String,
41    full_name: String,
42    private: bool,
43    owner: GiteaUser,
44    html_url: String,
45    ssh_url: String,
46    clone_url: String,
47    description: Option<String>,
48    #[serde(with = "time::serde::iso8601")]
49    created_at: OffsetDateTime,
50    #[serde(with = "time::serde::iso8601")]
51    updated_at: OffsetDateTime,
52    archived: bool,
53    default_branch: String,
54    stars_count: u32,
55    forks_count: u32,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    parent: Option<Box<GiteaRepository>>,
58}
59
60impl From<GiteaRepository> for Repository {
61    fn from(repo: GiteaRepository) -> Repository {
62        let GiteaRepository {
63            name,
64            full_name,
65            private,
66            owner,
67            html_url,
68            ssh_url,
69            clone_url,
70            description,
71            created_at,
72            updated_at,
73            archived,
74            default_branch,
75            stars_count,
76            forks_count,
77            parent,
78        } = repo;
79        Repository {
80            name,
81            full_name,
82            owner: Some(owner.into()),
83            visibility: if private {
84                RepositoryVisibility::Private
85            } else {
86                RepositoryVisibility::Public
87            },
88            html_url,
89            ssh_url,
90            https_url: clone_url,
91            description: description.unwrap_or_default(),
92            created_at,
93            updated_at,
94            archived,
95            default_branch,
96            stars_count,
97            forks_count,
98            forked_from: parent.map(|r| ForkedFromRepository::from(*r)),
99        }
100    }
101}
102
103impl From<GiteaRepository> for ForkedFromRepository {
104    fn from(repo: GiteaRepository) -> ForkedFromRepository {
105        let GiteaRepository {
106            name,
107            full_name,
108            html_url,
109            ..
110        } = repo;
111        ForkedFromRepository {
112            name,
113            full_name,
114            html_url,
115        }
116    }
117}
118
119#[derive(Debug, Deserialize, Serialize, Default)]
120struct GiteaCreateRepository {
121    name: String,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    description: Option<String>,
124    private: bool,
125    auto_init: bool,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    default_branch: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    license: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    gitignores: Option<String>,
132}
133
134#[derive(Debug, Deserialize, Serialize)]
135struct GiteaForkRepository {
136    #[serde(skip_serializing_if = "Option::is_none")]
137    name: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    organization: Option<String>,
140}
141
142#[derive(Debug, Deserialize, Serialize)]
143pub enum GiteaPullRequestState {
144    #[serde(rename = "open")]
145    Open,
146    #[serde(rename = "closed")]
147    Closed,
148}
149
150#[derive(Debug, Deserialize, Serialize)]
151pub struct GiteaPullRequestBranch {
152    #[serde(rename = "ref")]
153    pub branch: String,
154    pub sha: String,
155}
156
157#[derive(Debug, Deserialize, Serialize)]
158pub struct GiteaPullRequest {
159    pub id: u32,
160    pub number: u32,
161    pub state: GiteaPullRequestState,
162    pub title: String,
163    pub body: Option<String>,
164    pub head: GiteaPullRequestBranch,
165    pub base: GiteaPullRequestBranch,
166    pub html_url: String,
167    #[serde(with = "time::serde::iso8601")]
168    pub created_at: OffsetDateTime,
169    #[serde(with = "time::serde::iso8601")]
170    pub updated_at: OffsetDateTime,
171    #[serde(with = "time::serde::iso8601::option")]
172    pub merged_at: Option<OffsetDateTime>,
173    pub user: GiteaUser,
174    pub merged_by: Option<GiteaUser>,
175    pub requested_reviewers: Option<Vec<GiteaUser>>,
176}
177
178impl From<GiteaPullRequest> for PullRequest {
179    fn from(pr: GiteaPullRequest) -> PullRequest {
180        let GiteaPullRequest {
181            number,
182            state,
183            title,
184            body,
185            head,
186            base,
187            html_url,
188            created_at,
189            updated_at,
190            merged_at,
191            user,
192            merged_by,
193            requested_reviewers,
194            ..
195        } = pr;
196        PullRequest {
197            id: number,
198            state: match (state, merged_at) {
199                (GiteaPullRequestState::Open, _) => PullRequestState::Open,
200                (GiteaPullRequestState::Closed, Some(_)) => PullRequestState::Merged,
201                (GiteaPullRequestState::Closed, None) => PullRequestState::Closed,
202            },
203            title,
204            description: body.unwrap_or_default(),
205            source: head.branch,
206            target: base.branch,
207            source_sha: head.sha,
208            target_sha: base.sha,
209            url: html_url,
210            created_at,
211            updated_at,
212            author: user.into(),
213            closed_by: merged_by.map(|c| c.into()),
214            reviewers: requested_reviewers.map(|rs| rs.into_iter().map(|r| r.into()).collect()),
215            delete_source_branch: false,
216        }
217    }
218}
219
220#[derive(Debug, Deserialize, Serialize)]
221pub struct GiteaCreatePullRequest {
222    pub title: String,
223    pub body: String,
224    pub head: String,
225    pub base: String,
226}
227
228impl From<CreatePullRequest> for GiteaCreatePullRequest {
229    fn from(pr: CreatePullRequest) -> Self {
230        let CreatePullRequest {
231            title,
232            description,
233            source,
234            target: destination,
235            ..
236        } = pr;
237        Self {
238            title,
239            body: description,
240            head: source,
241            // We are never supposed to fallback to this, but handle it
242            base: destination.unwrap_or("master".to_string()),
243        }
244    }
245}
246
247#[derive(Debug, Deserialize, Serialize)]
248pub enum GiteaCreatePullRequestReviewEvent {
249    #[serde(rename = "APPROVE")]
250    Approve,
251    #[serde(rename = "REQUEST_CHANGES")]
252    RequestChanges,
253    #[serde(rename = "COMMENT")]
254    Comment,
255}
256
257#[derive(Debug, Deserialize, Serialize)]
258pub struct GiteaCreatePullRequestReview {
259    event: GiteaCreatePullRequestReviewEvent,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    body: Option<String>,
262}
263
264#[derive(Debug, Deserialize, Serialize)]
265pub struct GiteaCreatePullRequestReviewers {
266    reviewers: Vec<String>,
267}
268
269#[derive(Debug, Default, Deserialize, Serialize)]
270pub struct GiteaUpdatePullRequest {
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub title: Option<String>,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub body: Option<String>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub base: Option<String>,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub state: Option<GiteaPullRequestState>,
279}
280
281#[derive(Debug, Deserialize, Serialize)]
282pub enum GiteaMergePullRequestDo {
283    #[serde(rename = "merge")]
284    Merge,
285    #[serde(rename = "rebase")]
286    Rebase,
287    #[serde(rename = "rebase-merge")]
288    RebaseMerge,
289    #[serde(rename = "squash")]
290    Squash,
291    #[serde(rename = "manually-merged")]
292    ManuallyMerged,
293}
294
295#[derive(Debug, Deserialize, Serialize)]
296pub struct GiteaMergePullRequest {
297    #[serde(rename = "Do")]
298    pub do_this: GiteaMergePullRequestDo,
299}
300
301#[derive(Debug)]
302pub struct Gitea {
303    hostname: String,
304    settings: VersionControlSettings,
305    client: Agent,
306    repo: String,
307}
308
309impl Gitea {
310    #[instrument(skip_all)]
311    fn get_repository_url(&self, url: &str) -> String {
312        format!("/repos/{}{}", self.repo, url)
313    }
314    #[instrument(skip_all)]
315    fn call<T: DeserializeOwned, U: Serialize + Debug>(
316        &self,
317        method: &str,
318        url: &str,
319        body: Option<U>,
320    ) -> Result<T> {
321        let url = format!("https://{}/api/v1{}", self.hostname, url);
322
323        info!("Calling with {method} on {url}.");
324
325        let token = &self.settings.auth;
326
327        trace!("Authenticating with token '{token}'.");
328
329        let request = self
330            .client
331            .request(method, &url)
332            .set("Authorization", &format!("token {token}"))
333            .set("Content-Type", "application/json");
334        let result = if let Some(body) = &body {
335            trace!("Sending body: {}.", serde_json::to_string(&body)?);
336            request.send_json(body)
337        } else {
338            request.call()
339        };
340
341        match result {
342            Ok(result) => {
343                let status = result.status();
344                let mut t = result.into_string()?;
345
346                info!(
347                    "Received response with response code {} with body size {}.",
348                    status,
349                    t.len()
350                );
351                trace!("Response body: {t}.");
352
353                // Somewhat hacky, if the response is empty, return null
354                if t.is_empty() {
355                    t = "null".to_string();
356                }
357
358                let t: T = serde_json::from_str(&t)?;
359                Ok(t)
360            }
361            Err(Error::Status(status, result)) => {
362                let t = result.into_string()?;
363
364                info!(
365                    "Received response with response code {} with body size {}.",
366                    status,
367                    t.len()
368                );
369                Err(eyre!("Request failed (response: {}).", t))
370            }
371            Err(Error::Transport(_)) => Err(eyre!("Sending data failed.")),
372        }
373    }
374
375    #[instrument(skip_all)]
376    fn get_repository_data(&self) -> Result<GiteaRepository> {
377        self.call::<GiteaRepository, i32>("GET", &self.get_repository_url(""), None)
378    }
379
380    #[instrument(skip(self))]
381    fn call_paginated<T: DeserializeOwned>(&self, url: &str, params: &str) -> Result<Vec<T>> {
382        let mut collected_values: Vec<T> = vec![];
383        let mut i = 1;
384        loop {
385            info!("Reading page {}.", i);
386
387            let mut page: Vec<T> = self.call(
388                "GET",
389                &format!("{url}?page={i}{params}"),
390                None as Option<i32>,
391            )?;
392
393            if page.is_empty() {
394                break;
395            }
396
397            collected_values.append(&mut page);
398
399            i += 1;
400        }
401        Ok(collected_values)
402    }
403}
404
405impl VersionControl for Gitea {
406    #[instrument(skip_all)]
407    fn init(hostname: String, repo: String, settings: VersionControlSettings) -> Self {
408        let client = AgentBuilder::new()
409            .tls_connector(Arc::new(TlsConnector::new().unwrap()))
410            .build();
411        Gitea {
412            hostname,
413            settings,
414            client,
415            repo,
416        }
417    }
418
419    #[instrument(skip_all)]
420    fn login_url(&self) -> String {
421        format!("https://{}/user/settings/applications", self.hostname)
422    }
423
424    #[instrument(skip_all)]
425    fn validate_token(&self, token: &str) -> Result<()> {
426        if token.len() != 40 {
427            Err(eyre!("Your Gitea token has to be 40 characters long."))
428        } else {
429            Ok(())
430        }
431    }
432
433    #[instrument(skip(self))]
434    fn create_pr(&self, mut pr: CreatePullRequest) -> Result<PullRequest> {
435        let reviewers = pr.reviewers.clone();
436        pr.target = pr.target.or(self.settings.default_branch.clone());
437        if pr.target.is_none() {
438            let GiteaRepository { default_branch, .. } = self.get_repository_data()?;
439            pr.target = Some(default_branch);
440        }
441
442        let mut url = self.get_repository_url("/pulls");
443        let mut gitea_pr = GiteaCreatePullRequest::from(pr);
444        if self.settings.fork {
445            let repo = self.get_repository()?;
446            if let Some(forked) = repo.forked_from {
447                url = format!("/repos/{}/pulls", forked.full_name);
448
449                // For some reason Gitea only works with specifying head to username:branch
450                let namespace = repo
451                    .full_name
452                    .split('/')
453                    .next()
454                    .wrap_err(eyre!("Invalid repository name {}.", repo.full_name))?;
455                gitea_pr.head = format!("{}:{}", namespace, gitea_pr.head);
456            }
457        }
458
459        let new_pr: GiteaPullRequest = self.call("POST", &url, Some(gitea_pr))?;
460
461        let _: Vec<GiteaPullRequestReview> = self.call(
462            "POST",
463            &format!("{}/{}/requested_reviewers", url, new_pr.number),
464            Some(GiteaCreatePullRequestReviewers { reviewers }),
465        )?;
466
467        Ok(new_pr.into())
468    }
469
470    #[instrument(skip(self))]
471    fn get_pr_by_id(&self, id: u32) -> Result<PullRequest> {
472        let pr: GiteaPullRequest = self.call(
473            "GET",
474            &self.get_repository_url(&format!("/pulls/{id}")),
475            None as Option<i32>,
476        )?;
477
478        Ok(pr.into())
479    }
480
481    #[instrument(skip(self))]
482    fn get_pr_by_branch(&self, branch: &str) -> Result<PullRequest> {
483        let prs: Vec<GiteaPullRequest> =
484            self.call_paginated(&self.get_repository_url("/pulls"), "&state=all")?;
485
486        prs.into_iter()
487            .find(|pr| pr.head.branch == branch)
488            .map(|pr| pr.into())
489            .wrap_err(eyre!("Pull request on branch {branch} not found."))
490    }
491
492    #[instrument(skip(self))]
493    fn list_prs(&self, filters: ListPullRequestFilters) -> Result<Vec<PullRequest>> {
494        let state = match filters.state {
495            PullRequestStateFilter::Open => "open",
496            PullRequestStateFilter::Closed
497            | PullRequestStateFilter::Merged
498            | PullRequestStateFilter::Locked => "closed",
499            PullRequestStateFilter::All => "all",
500        };
501        let prs: Vec<GiteaPullRequest> = self.call(
502            "GET",
503            &self.get_repository_url(&format!("/pulls?state={state}")),
504            None as Option<i32>,
505        )?;
506
507        Ok(prs.into_iter().map(|pr| pr.into()).collect())
508    }
509
510    #[instrument(skip(self))]
511    fn approve_pr(&self, id: u32) -> Result<()> {
512        self.call(
513            "POST",
514            &self.get_repository_url(&format!("/pulls/{id}/reviews")),
515            Some(GiteaCreatePullRequestReview {
516                event: GiteaCreatePullRequestReviewEvent::Approve,
517                body: None,
518            }),
519        )?;
520
521        Ok(())
522    }
523
524    #[instrument(skip(self))]
525    fn close_pr(&self, id: u32) -> Result<PullRequest> {
526        let closing = GiteaUpdatePullRequest {
527            state: Some(GiteaPullRequestState::Closed),
528            ..GiteaUpdatePullRequest::default()
529        };
530        let pr: GiteaPullRequest = self.call(
531            "PATCH",
532            &self.get_repository_url(&format!("/pulls/{id}")),
533            Some(closing),
534        )?;
535
536        Ok(pr.into())
537    }
538
539    #[instrument(skip(self))]
540    fn merge_pr(&self, id: u32, _: bool) -> Result<PullRequest> {
541        self.call(
542            "POST",
543            &self.get_repository_url(&format!("/pulls/{id}/merge")),
544            Some(GiteaMergePullRequest {
545                do_this: GiteaMergePullRequestDo::Merge,
546            }),
547        )?;
548
549        self.get_pr_by_id(id)
550    }
551
552    #[instrument(skip_all)]
553    fn get_repository(&self) -> Result<Repository> {
554        let repo = self.get_repository_data()?;
555
556        Ok(repo.into())
557    }
558
559    #[instrument(skip_all)]
560    fn create_repository(&self, repo: CreateRepository) -> Result<Repository> {
561        let CreateRepository {
562            name,
563            description,
564            visibility,
565            organization,
566            init,
567            default_branch,
568            gitignore,
569            license,
570        } = repo;
571        let create_repo: GiteaCreateRepository = GiteaCreateRepository {
572            name,
573            description,
574            private: visibility != RepositoryVisibility::Public,
575            auto_init: init,
576            default_branch,
577            gitignores: gitignore,
578            license,
579        };
580        let new_repo: GiteaRepository = if let Some(org) = organization {
581            self.call("POST", &format!("/orgs/{org}/repos"), Some(create_repo))
582        } else {
583            self.call("POST", "/user/repos", Some(create_repo))
584        }?;
585
586        Ok(new_repo.into())
587    }
588
589    #[instrument(skip_all)]
590    fn fork_repository(&self, repo: ForkRepository) -> Result<Repository> {
591        let ForkRepository { name, organization } = repo;
592
593        let new_repo: GiteaRepository = self.call(
594            "POST",
595            &self.get_repository_url("/forks"),
596            Some(GiteaForkRepository { organization, name }),
597        )?;
598
599        Ok(new_repo.into())
600    }
601
602    #[instrument(skip_all)]
603    fn delete_repository(&self) -> Result<()> {
604        self.call("DELETE", &self.get_repository_url(""), None as Option<i32>)?;
605
606        Ok(())
607    }
608}