gr_bin/vcs/
github.rs

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