ghostflow_github/
ghostflow.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7#![allow(unreachable_code)]
8#![allow(unused_variables)]
9
10use std::fmt::{self, Debug};
11
12use chrono::Utc;
13use ghostflow::host::*;
14use git_workarea::{CommitId, GitContext};
15use graphql_client::GraphQLQuery;
16use log::{error, warn};
17use serde_json::json;
18use thiserror::Error;
19
20use crate::authorization::CurrentUser;
21use crate::client::Github;
22use crate::queries;
23
24const WORK_IN_PROGRESS_PREFIXES: &[&str] = &["WIP", "wip"];
25
26impl From<CurrentUser> for User {
27    fn from(user: CurrentUser) -> Self {
28        Self {
29            handle: user.login,
30            email: user.email,
31            name: user.name,
32        }
33    }
34}
35
36macro_rules! impl_from_reaction_content {
37    ($type:path) => {
38        impl From<$type> for String {
39            fn from(reaction: $type) -> Self {
40                use $type::*;
41                match reaction {
42                    CONFUSED => "confused".into(),
43                    EYES => "eyes".into(),
44                    HEART => "heart".into(),
45                    HOORAY => "hooray".into(),
46                    LAUGH => "laugh".into(),
47                    ROCKET => "rocket".into(),
48                    THUMBS_DOWN => "-1".into(),
49                    THUMBS_UP => "+1".into(),
50                    Other(s) => s,
51                }
52            }
53        }
54    };
55}
56
57impl_from_reaction_content!(queries::pull_request_reactions::ReactionContent);
58
59macro_rules! impl_from_user_info {
60    ($type:path) => {
61        impl From<$type> for User {
62            fn from(user: $type) -> Self {
63                let $type {
64                    name,
65                    login,
66                    // email,
67                } = user;
68
69                Self {
70                    name: name.unwrap_or_else(|| login.clone()),
71                    // TODO(github-enterprise): What email to use here?
72                    email: format!("{}@users.noreply.github.com", login),
73                    handle: login,
74                }
75            }
76        }
77    };
78}
79
80impl_from_user_info!(queries::pull_request_comments::UserInfo);
81impl_from_user_info!(queries::pull_request_reactions::UserInfo);
82impl_from_user_info!(queries::user::UserInfo);
83
84macro_rules! impl_from_author_info {
85    ($type:path) => {
86        impl From<$type> for User {
87            fn from(author: $type) -> Self {
88                use $type::*;
89                match author {
90                    Bot(bot) => {
91                        Self {
92                            handle: bot.login.clone(),
93                            // TODO(github-enterprise): What email to use here?
94                            email: format!("{}@users.noreply.github.com", bot.login),
95                            name: bot.login,
96                        }
97                    },
98                    Mannequin(mannequin) => {
99                        let (login, email) = (mannequin.login, mannequin.email);
100
101                        Self {
102                            handle: login.clone(),
103                            email: email
104                                // TODO(github-enterprise): What email to use here?
105                                .unwrap_or_else(|| {
106                                    format!("{}@mannequin.noreply.github.com", login)
107                                }),
108                            name: login,
109                        }
110                    },
111                    Organization(org) => {
112                        let name = org.name;
113                        let login = org.login;
114
115                        Self {
116                            name: name.unwrap_or_else(|| login.clone()),
117                            // TODO(github-enterprise): What email to use here?
118                            email: format!("{}@users.noreply.github.com", login),
119                            handle: login,
120                        }
121                    },
122                    User(user) => {
123                        let name = user.name;
124                        let login = user.login;
125
126                        Self {
127                            name: name.unwrap_or_else(|| login.clone()),
128                            // TODO(github-enterprise): What email to use here?
129                            email: format!("{}@users.noreply.github.com", login),
130                            handle: login,
131                        }
132                    },
133                    EnterpriseUserAccount(user) => {
134                        let name = user.name;
135                        let login = user.login;
136
137                        Self {
138                            name: name.unwrap_or_else(|| login.clone()),
139                            // TODO(github-enterprise): What email to use here?
140                            email: format!("{}@users.noreply.github.com", login),
141                            handle: login,
142                        }
143                    },
144                }
145            }
146        }
147    };
148}
149
150impl_from_author_info!(queries::pull_request::PullRequestInfoAuthor);
151impl_from_author_info!(queries::pull_request_comments::IssueCommentInfoAuthor);
152impl_from_author_info!(queries::pull_request_comments::PullRequestReviewInfoAuthor);
153
154macro_rules! impl_from_comment_info {
155    ($type:path) => {
156        impl From<$type> for Option<Comment> {
157            fn from(comment: $type) -> Self {
158                let $type {
159                    id: comment_id,
160                    author,
161                    created_at,
162                    content,
163                } = comment;
164
165                author.map(|author| {
166                    Comment {
167                        id: comment_id,
168                        is_system: false,
169                        is_branch_update: false,
170                        created_at,
171                        author: author.into(),
172                        content,
173                    }
174                })
175            }
176        }
177    };
178}
179
180impl_from_comment_info!(queries::pull_request_comments::IssueCommentInfo);
181impl_from_comment_info!(queries::pull_request_comments::PullRequestReviewInfo);
182
183impl From<queries::commit_statuses::CheckConclusionState> for CommitStatusState {
184    fn from(state: queries::commit_statuses::CheckConclusionState) -> Self {
185        use queries::commit_statuses::CheckConclusionState;
186        match state {
187            CheckConclusionState::ACTION_REQUIRED
188            | CheckConclusionState::FAILURE
189            | CheckConclusionState::STARTUP_FAILURE
190            | CheckConclusionState::TIMED_OUT => CommitStatusState::Failed,
191            CheckConclusionState::SKIPPED
192            | CheckConclusionState::STALE
193            | CheckConclusionState::CANCELLED => CommitStatusState::Pending,
194            CheckConclusionState::NEUTRAL | CheckConclusionState::SUCCESS => {
195                CommitStatusState::Success
196            },
197            CheckConclusionState::Other(s) => {
198                error!(target: "github", "new GitHub conclusion state: {s}");
199                CommitStatusState::Failed
200            },
201        }
202    }
203}
204
205/*
206fn extract_run_state(
207    state: CommitStatusState,
208) -> (
209    Option<queries::post_check_run::CheckConclusionState>,
210    queries::post_check_run::RequestableCheckStatusState,
211) {
212    use queries::post_check_run::CheckConclusionState::*;
213    use queries::post_check_run::RequestableCheckStatusState::*;
214    match state {
215        CommitStatusState::Pending => (None, QUEUED),
216        CommitStatusState::Running => (None, IN_PROGRESS),
217        CommitStatusState::Success => (Some(SUCCESS), COMPLETED),
218        CommitStatusState::Failed => (Some(FAILURE), COMPLETED),
219    }
220}
221*/
222
223fn extract_run_state_rest(state: CommitStatusState) -> (Option<&'static str>, &'static str) {
224    match state {
225        CommitStatusState::Pending => (None, "queued"),
226        CommitStatusState::Running => (None, "in_progress"),
227        CommitStatusState::Success => (Some("success"), "completed"),
228        CommitStatusState::Failed => (Some("failure"), "completed"),
229    }
230}
231
232/// Structure used to communicate with a Github instance.
233///
234/// The API calls associated with this structure assume that the following permissions in GitHub
235/// have been granted to the application:
236///
237///   - Read & write
238///     * Checks
239///     * Issues
240///     * Pull requests
241///   - Read-only
242///     * Repository contents
243///     * Repository metadata
244///
245/// User permissions should include read-only access to email addresses. Note that this does not
246/// currently work however and even with that permission, reading email addresses is being denied.
247pub struct GithubService {
248    /// The Github client.
249    github: Github,
250    /// The user the service is acting as.
251    user: User,
252}
253
254const GITHUB_CHECK_RUN_MESSAGE_LIMIT: usize = 65535;
255const GITHUB_OVERFLOW_INDICATOR: &str = "**Contents exceed GitHub check limits**\n\n";
256
257fn trim_to_check_run_limit(text: String) -> String {
258    if text.len() > GITHUB_CHECK_RUN_MESSAGE_LIMIT {
259        warn!(target: "github", "Check results comment exceeding limits: {text}");
260
261        format!(
262            "{}{}",
263            // Prepend because we have no idea what the Markdown parser state will be where we
264            // truncate the intended message.
265            GITHUB_OVERFLOW_INDICATOR,
266            &text[..GITHUB_CHECK_RUN_MESSAGE_LIMIT - GITHUB_OVERFLOW_INDICATOR.len()],
267        )
268    } else {
269        text
270    }
271}
272
273impl GithubService {
274    /// Create a new Github communication channel.
275    pub fn new(github: Github) -> Result<Self, HostingServiceError> {
276        let user = github.current_user().map_err(HostingServiceError::host)?;
277
278        Ok(Self {
279            user: user.into(),
280            github,
281        })
282    }
283
284    /// Splits a project name in to an owner, name pair.
285    pub fn split_project(project: &str) -> Result<(&str, &str), HostingServiceError> {
286        let mut split = project.split('/');
287        if let Some(owner) = split.next() {
288            if let Some(name) = split.next() {
289                Ok((owner, name))
290            } else {
291                Err(GithubServiceError::missing_repository(project.into()).into())
292            }
293        } else {
294            Err(GithubServiceError::missing_owner(project.into()).into())
295        }
296    }
297
298    /// Create a repository from a Github project.
299    fn repo<R>(&self, project: R) -> Result<Repo, HostingServiceError>
300    where
301        R: queries::RepoInfo,
302    {
303        self.repo_impl(
304            project.name(),
305            project.ssh_url(),
306            project.http_url(),
307            project.parent(),
308        )
309    }
310
311    /// Create a repository from a Github project.
312    fn repo_impl(
313        &self,
314        name: String,
315        ssh_url: &str,
316        http_url: &str,
317        parent: Option<queries::RepoParentInfo>,
318    ) -> Result<Repo, HostingServiceError> {
319        let parent_project = if let Some(parent_info) = parent {
320            let queries::RepoParentInfo {
321                owner,
322                name,
323                ssh_url,
324                http_url,
325                parent: grand_parent,
326            } = parent_info;
327
328            let grand_parent = if let Some((owner, name)) = grand_parent {
329                let vars = queries::repository::Variables {
330                    owner: owner.into(),
331                    name: name.into(),
332                };
333                let query = queries::Repository::build_query(vars);
334                let grand_parent_project = self
335                    .github
336                    .send::<queries::Repository>(owner, &query)
337                    .map_err(HostingServiceError::host)
338                    .and_then(|rsp| {
339                        Ok(rsp
340                            .repository
341                            .ok_or_else(|| GithubHostError::no_repository(name.into()))?)
342                    })?;
343                Some(Box::new(self.repo(grand_parent_project)?))
344            } else {
345                None
346            };
347
348            Some(Box::new(Repo {
349                name: format!("{owner}/{name}"),
350                url: ssh_url.into(),
351                http_url: http_url.into(),
352                forked_from: grand_parent,
353            }))
354        } else {
355            None
356        };
357
358        Ok(Repo {
359            name,
360            url: ssh_url.into(),
361            http_url: http_url.into(),
362            forked_from: parent_project,
363        })
364    }
365
366    /// Create a comment.
367    pub fn post_comment<C>(
368        &self,
369        owner: &str,
370        id: String,
371        content: C,
372    ) -> Result<(), HostingServiceError>
373    where
374        C: Into<String>,
375    {
376        let input = queries::post_comment::Variables {
377            input: queries::post_comment::AddCommentInput {
378                // TODO: Make a mutation ID.
379                client_mutation_id: None,
380                subject_id: id,
381                body: content.into(),
382            },
383        };
384        let mutation = queries::PostComment::build_query(input);
385        self.github
386            .send::<queries::PostComment>(owner, &mutation)
387            .map_err(HostingServiceError::host)?;
388
389        Ok(())
390    }
391
392    /// Create a check run.
393    fn post_check_run(
394        &self,
395        status: PendingCommitStatus,
396        description: Option<String>,
397    ) -> Result<(), HostingServiceError> {
398        let project = &status.commit.repo.name;
399        let (owner, name) = Self::split_project(project)?;
400
401        let endpoint = format!("repos/{owner}/{name}/check-runs");
402        let (conclusion, status_state) = extract_run_state_rest(status.state);
403        let output = if let Some(description) = description {
404            let description = trim_to_check_run_limit(description);
405
406            json!({
407                "title": status.name,
408                "summary": status.description,
409                "text": description,
410            })
411        } else {
412            json!({
413                "title": status.name,
414                "summary": status.description,
415            })
416        };
417        let mut data = if let Some(conclusion) = conclusion {
418            json!({
419                "name": status.name,
420                "head_sha": status.commit.id.as_str(),
421                "status": status_state,
422                "conclusion": conclusion,
423                "completed_at": Utc::now(),
424                "output": output,
425            })
426        } else {
427            json!({
428                "name": status.name,
429                "head_sha": status.commit.id.as_str(),
430                "status": status_state,
431                "output": output,
432            })
433        };
434        if let Some(target_url) = status.target_url {
435            data.as_object_mut()
436                .expect("`data` is always constructed as an object")
437                .insert("details_url".into(), target_url.into());
438        }
439        self.github
440            .post(owner, &endpoint, &data)
441            .map_err(HostingServiceError::host)?;
442
443        /*
444        // XXX(github): The GraphQL API is busted right now.
445        // https://platform.github.community/t/error-when-creating-a-check-run/7360
446        let vars = queries::repository_id::Variables {
447            owner: owner.into(),
448            name: name.into(),
449        };
450        let query = queries::RepositoryID::build_query(vars);
451        let repository_id = self.github
452            .send::<queries::RepositoryID>(owner, &query)
453            .compat()
454            .map_err(HostingServiceError::host)
455            .and_then(|rsp| {
456                Self::check_rate_limits(&rsp.rate_limit_info.rate_limit, queries::RepositoryID::name());
457                Ok(rsp.repository
458                    .ok_or_else(|| GithubHostError::no_repository(project))?)
459            })?
460            .id;
461
462        let (conclusion, status_state) = extract_run_state(status.state);
463        let input = queries::post_check_run::Variables {
464            input: queries::post_check_run::CreateCheckRunInput {
465                // TODO: Make a mutation ID.
466                client_mutation_id: None,
467                actions: None,
468                completed_at: conclusion.map(|_| Utc::now()),
469                conclusion: conclusion,
470                details_url: status.target_url.map(Into::into),
471                external_id: None,
472                head_sha: status.commit.id.as_str().into(),
473                name: status.name.into(),
474                output: Some(queries::post_check_run::CheckRunOutput {
475                    annotations: None,
476                    images: None,
477                    summary: status.description.into(),
478                    text: description,
479                    title: status.name.into(),
480                }),
481                repository_id: repository_id,
482                started_at: None,
483                status: Some(status_state),
484            },
485        };
486        let mutation = queries::PostCheckRun::build_query(input);
487        self.github
488            .send::<queries::PostCheckRun>(owner, &mutation)
489            .compat()
490            .map_err(HostingServiceError::host)?;
491        */
492
493        Ok(())
494    }
495
496    /// Check the rate limiting for a query.
497    pub fn check_rate_limits<R>(rate_limit: &Option<R>, name: &str)
498    where
499        R: Into<queries::RateLimitInfo> + Clone,
500    {
501        if let Some(info) = rate_limit.as_ref() {
502            info.clone().into().inspect(name);
503        }
504    }
505
506    /// Access the GitHub client.
507    pub fn github(&self) -> &Github {
508        &self.github
509    }
510
511    /// Common logic for manipulating issue labels.
512    fn issue_label_information(
513        &self,
514        issue: &Issue,
515        labels: &[&str],
516    ) -> Result<(String, Vec<String>), HostingServiceError> {
517        let project = &issue.repo.name;
518        let id = issue.id;
519        let (owner, name) = Self::split_project(project)?;
520
521        let vars = queries::issue_id::Variables {
522            owner: owner.into(),
523            name: name.into(),
524            issue: id as i64,
525        };
526        let query = queries::IssueID::build_query(vars);
527        let issue_id = self
528            .github
529            .send::<queries::IssueID>(owner, &query)
530            .map_err(HostingServiceError::host)
531            .and_then(|rsp| {
532                Self::check_rate_limits(&rsp.rate_limit_info.rate_limit, queries::IssueID::name());
533                Ok(rsp
534                    .repository
535                    .ok_or_else(|| GithubHostError::no_repository(project.clone()))?)
536            })
537            .and_then(|rsp| {
538                Ok(rsp
539                    .issue
540                    .ok_or_else(|| GithubHostError::no_issue(id, project.clone()))?)
541            })?
542            .id;
543
544        let label_ids = labels
545            .iter()
546            .map(|&label| {
547                let vars = queries::label_id::Variables {
548                    owner: owner.into(),
549                    name: name.into(),
550                    label: label.into(),
551                };
552                let query = queries::LabelID::build_query(vars);
553                Ok(self
554                    .github
555                    .send::<queries::LabelID>(owner, &query)
556                    .map_err(HostingServiceError::host)
557                    .and_then(|rsp| {
558                        Self::check_rate_limits(
559                            &rsp.rate_limit_info.rate_limit,
560                            queries::LabelID::name(),
561                        );
562                        Ok(rsp
563                            .repository
564                            .ok_or_else(|| GithubHostError::no_repository(project.clone()))?)
565                    })
566                    .and_then(|rsp| {
567                        Ok(rsp.label.ok_or_else(|| {
568                            GithubHostError::no_label(label.into(), project.clone())
569                        })?)
570                    })?
571                    .id)
572            })
573            .collect::<Result<_, HostingServiceError>>()?;
574
575        Ok((issue_id, label_ids))
576    }
577}
578
579#[derive(Debug, Error)]
580#[non_exhaustive]
581enum GithubHostError {
582    #[error("no repository named {}", project)]
583    NoRepository { project: String },
584    #[error("no user named {}", user)]
585    NoUser { user: String },
586    #[error("no such object {}@{}", project, object)]
587    NoObject { object: CommitId, project: String },
588    #[error("{}@{} is not a commit", project, object)]
589    NotCommit { object: CommitId, project: String },
590    #[error("no such issue {}#{}", project, issue)]
591    NoIssue { issue: u64, project: String },
592    #[error("no such pull {}#{}", project, pull)]
593    NoPull { pull: u64, project: String },
594    #[error("no author for pull {}#{}", project, pull)]
595    NoPullAuthor { pull: u64, project: String },
596    #[error("no label {} in {}", label, project)]
597    NoLabel { label: String, project: String },
598    #[error("no pull timeline edges on {}#{}", project, pull)]
599    NoPullTimelineEdges { pull: u64, project: String },
600    #[error("no pull reaction edges on {}#{}", project, pull)]
601    NoPullReactionEdges { pull: u64, project: String },
602    #[error("no issues closed by pr edges on {}#{}", project, pull)]
603    NoIssuesClosedByPrEdges { pull: u64, project: String },
604    #[error("no closing issues found on pr {}#{}", project, pull)]
605    NoClosingIssues { pull: u64, project: String },
606}
607
608impl GithubHostError {
609    fn no_repository(project: String) -> Self {
610        GithubHostError::NoRepository {
611            project,
612        }
613    }
614
615    fn no_user(user: String) -> Self {
616        GithubHostError::NoUser {
617            user,
618        }
619    }
620
621    fn no_object(object: CommitId, project: String) -> Self {
622        GithubHostError::NoObject {
623            object,
624            project,
625        }
626    }
627
628    fn not_commit(object: CommitId, project: String) -> Self {
629        GithubHostError::NotCommit {
630            object,
631            project,
632        }
633    }
634
635    fn no_issue(issue: u64, project: String) -> Self {
636        GithubHostError::NoIssue {
637            issue,
638            project,
639        }
640    }
641
642    fn no_pull(pull: u64, project: String) -> Self {
643        GithubHostError::NoPull {
644            pull,
645            project,
646        }
647    }
648
649    fn no_pull_author(pull: u64, project: String) -> Self {
650        GithubHostError::NoPullAuthor {
651            pull,
652            project,
653        }
654    }
655
656    fn no_label(label: String, project: String) -> Self {
657        GithubHostError::NoLabel {
658            label,
659            project,
660        }
661    }
662
663    fn no_pull_timeline_edges(pull: u64, project: String) -> Self {
664        GithubHostError::NoPullTimelineEdges {
665            pull,
666            project,
667        }
668    }
669
670    fn no_pull_reaction_edges(pull: u64, project: String) -> Self {
671        GithubHostError::NoPullReactionEdges {
672            pull,
673            project,
674        }
675    }
676
677    fn no_issues_closed_by_pr_edges(pull: u64, project: String) -> Self {
678        GithubHostError::NoIssuesClosedByPrEdges {
679            pull,
680            project,
681        }
682    }
683
684    fn no_closing_issues(pull: u64, project: String) -> Self {
685        GithubHostError::NoClosingIssues {
686            pull,
687            project,
688        }
689    }
690}
691
692impl From<GithubHostError> for HostingServiceError {
693    fn from(github: GithubHostError) -> Self {
694        HostingServiceError::host(github)
695    }
696}
697
698#[derive(Debug, Error)]
699#[non_exhaustive]
700enum GithubServiceError {
701    #[error("missing repository name in {}", project)]
702    MissingRepository { project: String },
703    #[error("missing owner name in {}", project)]
704    MissingOwner { project: String },
705}
706
707impl GithubServiceError {
708    fn missing_repository(project: String) -> Self {
709        GithubServiceError::MissingRepository {
710            project,
711        }
712    }
713
714    fn missing_owner(project: String) -> Self {
715        GithubServiceError::MissingOwner {
716            project,
717        }
718    }
719}
720
721impl From<GithubServiceError> for HostingServiceError {
722    fn from(github: GithubServiceError) -> Self {
723        HostingServiceError::service(github)
724    }
725}
726
727impl HostingService for GithubService {
728    fn fetch_mr(&self, git: &GitContext, mr: &MergeRequest) -> Result<(), HostingServiceError> {
729        git.fetch(&mr.target_repo.url, [&format!("refs/pull/{}/head", mr.id)])
730            .map_err(HostingServiceError::fetch)
731    }
732
733    fn service_user(&self) -> &User {
734        &self.user
735    }
736
737    fn user(&self, project: &str, user: &str) -> Result<User, HostingServiceError> {
738        let (owner, _) = Self::split_project(project)?;
739
740        // XXX(github): Querying for bots by username has no endpoint at the moment. Just
741        // synthesize the data.
742        if user.ends_with("[bot]") {
743            return Ok(User {
744                handle: user.into(),
745                name: user.into(),
746                // TODO(github-enterprise): What email to use here?
747                email: format!("{user}@users.noreply.github.com"),
748            });
749        }
750
751        let vars = queries::user::Variables {
752            name: user.into(),
753        };
754        let query = queries::User::build_query(vars);
755        Ok(self
756            .github
757            .send::<queries::User>(owner, &query)
758            .map_err(HostingServiceError::host)
759            .and_then(|rsp| {
760                Self::check_rate_limits(&rsp.rate_limit_info.rate_limit, queries::User::name());
761                Ok(rsp
762                    .user
763                    .ok_or_else(|| GithubHostError::no_user(user.into()))?)
764            })?
765            .into())
766    }
767
768    fn commit(&self, project: &str, commit: &CommitId) -> Result<Commit, HostingServiceError> {
769        let (owner, name) = Self::split_project(project)?;
770
771        let vars = queries::commit::Variables {
772            owner: owner.into(),
773            name: name.into(),
774            commit: commit.as_str().into(),
775        };
776        let query = queries::Commit::build_query(vars);
777        self.github
778            .send::<queries::Commit>(owner, &query)
779            .map_err(HostingServiceError::host)
780            .and_then(|rsp| {
781                Self::check_rate_limits(&rsp.rate_limit_info.rate_limit, queries::Commit::name());
782                Ok(rsp
783                    .repository
784                    .ok_or_else(|| GithubHostError::no_repository(project.into()))?)
785            })
786            .and_then(|repo| {
787                Ok(repo
788                    .object
789                    .ok_or_else(|| GithubHostError::no_object(commit.clone(), project.into()))?)
790            })
791            .and_then(|object| {
792                let (repo, oid, object) = (object.repository, object.oid, object.on);
793
794                use queries::commit::CommitRepositoryObjectOn;
795                let oid = if let CommitRepositoryObjectOn::Commit = object {
796                    oid
797                } else {
798                    return Err(GithubHostError::not_commit(commit.clone(), project.into()).into());
799                };
800
801                Ok(Commit {
802                    repo: self.repo(repo)?,
803                    refname: None,
804                    id: CommitId::new(oid),
805                    // Github does have a "databaseId" for a given check suite, but does not
806                    // expose a way to *query* on it.
807                    last_pipeline: None,
808                })
809            })
810    }
811
812    fn merge_request(&self, project: &str, id: u64) -> Result<MergeRequest, HostingServiceError> {
813        let (owner, name) = Self::split_project(project)?;
814
815        let vars = queries::pull_request::Variables {
816            owner: owner.into(),
817            name: name.into(),
818            pull: id as i64,
819        };
820        let query = queries::PullRequest::build_query(vars);
821        self.github
822            .send::<queries::PullRequest>(owner, &query)
823            .map_err(HostingServiceError::host)
824            .and_then(|rsp| {
825                Self::check_rate_limits(
826                    &rsp.rate_limit_info.rate_limit,
827                    queries::PullRequest::name(),
828                );
829                Ok(rsp
830                    .repository
831                    .ok_or_else(|| GithubHostError::no_repository(project.into()))?)
832            })
833            .and_then(|repo| {
834                Ok(repo
835                    .pull_request
836                    .ok_or_else(|| GithubHostError::no_pull(id, project.into()))?)
837            })
838            .and_then(|pull| {
839                let queries::pull_request::PullRequestInfo {
840                    source_repo,
841                    source_branch,
842                    target_repo,
843                    target_branch,
844                    url,
845                    title,
846                    description,
847                    head_ref_oid,
848                    author,
849                    is_draft,
850                } = pull;
851
852                let target_repo = self.repo(target_repo)?;
853
854                Ok(MergeRequest {
855                    // TODO(github): Is this `None` if the source repo is also the target repo?
856                    // There is an `isCrossRepository` flag on pull requests.
857                    source_repo: if let Some(repo) = source_repo {
858                        Some(self.repo(repo)?)
859                    } else {
860                        None
861                    },
862                    source_branch: source_branch.clone(),
863                    target_repo: target_repo.clone(),
864                    target_branch,
865                    id,
866                    url,
867                    work_in_progress: is_draft
868                        || WORK_IN_PROGRESS_PREFIXES
869                            .iter()
870                            .any(|prefix| title.starts_with(prefix)),
871                    description,
872                    old_commit: None,
873                    commit: Commit {
874                        repo: target_repo,
875                        refname: Some(source_branch),
876                        id: CommitId::new(head_ref_oid),
877                        // Github does have a "databaseId" for a given check suite, but does not
878                        // expose a way to *query* on it.
879                        last_pipeline: None,
880                    },
881                    author: author
882                        .ok_or_else(|| GithubHostError::no_pull_author(id, project.into()))?
883                        .into(),
884                    reference: format!("#{id}"),
885                    remove_source_branch: false,
886                })
887            })
888    }
889
890    fn repo(&self, project: &str) -> Result<Repo, HostingServiceError> {
891        let (owner, name) = Self::split_project(project)?;
892
893        let vars = queries::repository::Variables {
894            owner: owner.into(),
895            name: name.into(),
896        };
897        let query = queries::Repository::build_query(vars);
898        self.repo(
899            self.github
900                .send::<queries::Repository>(owner, &query)
901                .map_err(HostingServiceError::host)
902                .and_then(|rsp| {
903                    Self::check_rate_limits(
904                        &rsp.rate_limit_info.rate_limit,
905                        queries::Repository::name(),
906                    );
907                    Ok(rsp
908                        .repository
909                        .ok_or_else(|| GithubHostError::no_repository(project.into()))?)
910                })?,
911        )
912    }
913
914    fn get_mr_comments(&self, mr: &MergeRequest) -> Result<Vec<Comment>, HostingServiceError> {
915        let project = &mr.target_repo.name;
916        let id = mr.id;
917        let (owner, name) = Self::split_project(project)?;
918
919        let mut vars = queries::pull_request_comments::Variables {
920            owner: owner.into(),
921            name: name.into(),
922            pull: id as i64,
923            cursor: None,
924        };
925
926        let mut comments = Vec::new();
927        loop {
928            let query = queries::PullRequestComments::build_query(vars.clone());
929            let page_timeline = self
930                .github
931                .send::<queries::PullRequestComments>(owner, &query)
932                .map_err(HostingServiceError::host)
933                .and_then(|rsp| {
934                    Self::check_rate_limits(
935                        &rsp.rate_limit_info.rate_limit,
936                        queries::PullRequestComments::name(),
937                    );
938                    Ok(rsp
939                        .repository
940                        .ok_or_else(|| GithubHostError::no_repository(project.clone()))?)
941                })
942                .and_then(|rsp| {
943                    Ok(rsp
944                        .pull_request
945                        .ok_or_else(|| GithubHostError::no_pull(id, project.clone()))?)
946                })?
947                .timeline_items;
948            let (items, page_info) = (
949                page_timeline
950                    .items
951                    .ok_or_else(|| GithubHostError::no_pull_timeline_edges(id, project.into()))?,
952                page_timeline.page_info,
953            );
954
955            comments.extend(
956                items.into_iter()
957                    .filter_map(|item| {
958                        use queries::pull_request_comments::PullRequestCommentsRepositoryPullRequestTimelineItemsItems::*;
959                        match item {
960                            Some(PullRequestCommit(pr_commit)) => {
961                                let queries::pull_request_comments::CommitInfo {
962                                    oid: commit_id,
963                                    committed_date,
964                                    author,
965                                    message,
966                                } = pr_commit.commit;
967
968                                author.map(|author| {
969                                    let queries::pull_request_comments::CommitInfoAuthor {
970                                        user,
971                                        name,
972                                        email,
973                                    } = author;
974
975                                    Comment {
976                                        id: commit_id,
977                                        is_system: true,
978                                        is_branch_update: true,
979                                        created_at: committed_date,
980                                        author: user.map(|user| user.into())
981                                            .unwrap_or_else(|| {
982                                                // XXX(github): We can't really drop things just
983                                                // because we don't have data since the ghostflow
984                                                // code expects to be able to find when the last
985                                                // push happened and these "comments" fulfill that
986                                                // use case. If anything is missing, just use who
987                                                // we are communicating as.
988                                                User {
989                                                    name: name.unwrap_or_else(|| self.user.name.clone()),
990                                                    email: email.unwrap_or_else(|| self.user.name.clone()),
991                                                    handle: self.user.handle.clone(),
992                                                }
993                                            }),
994                                        content: message,
995                                    }
996                                })
997                            },
998                            Some(IssueComment(comment)) => {
999                                comment.into()
1000                            },
1001                            Some(PullRequestReview(review)) => {
1002                                review.into()
1003                            },
1004                            _ => None,
1005                        }
1006                    })
1007            );
1008
1009            if page_info.has_next_page {
1010                // XXX: We are assuming that if `has_next_page` is `true` that we'll have an
1011                // `end_cursor`.
1012                assert!(
1013                    page_info.end_cursor.is_some(),
1014                    "GitHub gave us a new page without a cursor to follow.",
1015                );
1016                vars.cursor = page_info.end_cursor;
1017            } else {
1018                break;
1019            }
1020        }
1021
1022        Ok(comments)
1023    }
1024
1025    fn post_mr_comment(&self, mr: &MergeRequest, content: &str) -> Result<(), HostingServiceError> {
1026        let project = &mr.target_repo.name;
1027        let id = mr.id;
1028        let (owner, name) = Self::split_project(project)?;
1029
1030        let vars = queries::pull_request_id::Variables {
1031            owner: owner.into(),
1032            name: name.into(),
1033            pull: id as i64,
1034        };
1035        let query = queries::PullRequestID::build_query(vars);
1036        let pull_request_id = self
1037            .github
1038            .send::<queries::PullRequestID>(owner, &query)
1039            .map_err(HostingServiceError::host)
1040            .and_then(|rsp| {
1041                Self::check_rate_limits(
1042                    &rsp.rate_limit_info.rate_limit,
1043                    queries::PullRequestID::name(),
1044                );
1045                Ok(rsp
1046                    .repository
1047                    .ok_or_else(|| GithubHostError::no_repository(project.clone()))?)
1048            })
1049            .and_then(|rsp| {
1050                Ok(rsp
1051                    .pull_request
1052                    .ok_or_else(|| GithubHostError::no_pull(id, project.clone()))?)
1053            })?
1054            .id;
1055
1056        self.post_comment(owner, pull_request_id, content)
1057    }
1058
1059    fn get_commit_statuses(
1060        &self,
1061        commit: &Commit,
1062    ) -> Result<Vec<CommitStatus>, HostingServiceError> {
1063        let project = &commit.repo.name;
1064        let oid = commit.id.as_str();
1065        let (owner, name) = Self::split_project(project)?;
1066
1067        let vars = queries::commit_statuses::Variables {
1068            owner: owner.into(),
1069            name: name.into(),
1070            commit: oid.into(),
1071            app_id: self.github.app_id(),
1072        };
1073
1074        let query = queries::CommitStatuses::build_query(vars);
1075        let check_suite = self
1076            .github
1077            .send::<queries::CommitStatuses>(owner, &query)
1078            .map_err(HostingServiceError::host)
1079            .and_then(|rsp| {
1080                Self::check_rate_limits(
1081                    &rsp.rate_limit_info.rate_limit,
1082                    queries::CommitStatuses::name(),
1083                );
1084                Ok(rsp
1085                    .repository
1086                    .ok_or_else(|| GithubHostError::no_repository(project.clone()))?)
1087            })
1088            .and_then(|rsp| {
1089                let object = rsp.object.ok_or_else(|| {
1090                    GithubHostError::no_object(CommitId::new(oid), project.clone())
1091                })?;
1092
1093                use queries::commit_statuses::CommitStatusesRepositoryObject;
1094                if let CommitStatusesRepositoryObject::Commit(commit) = object {
1095                    Ok(commit)
1096                } else {
1097                    Err(GithubHostError::not_commit(CommitId::new(oid), project.clone()).into())
1098                }
1099            })?
1100            .check_suites
1101            .and_then(|check_suites| check_suites.check_suite)
1102            .unwrap_or_default()
1103            .into_iter()
1104            .next()
1105            .and_then(|check_suite| check_suite);
1106        let check_suite = if let Some(check_suite) = check_suite {
1107            check_suite
1108        } else {
1109            return Ok(Vec::new());
1110        };
1111        let (branch, check_runs) = (
1112            check_suite.branch.map(|branch| branch.name),
1113            check_suite
1114                .check_runs
1115                .and_then(|check_runs| check_runs.check_runs)
1116                .unwrap_or_default(),
1117        );
1118
1119        Ok(check_runs.into_iter()
1120            .filter_map(|check_run| {
1121                check_run.and_then(|check_run| {
1122                    let queries::commit_statuses::CommitStatusesRepositoryObjectOnCommitCheckSuitesCheckSuiteCheckRunsCheckRuns {
1123                        conclusion,
1124                        name,
1125                        summary,
1126                        details_url,
1127                    } = check_run;
1128
1129                    conclusion.map(|conclusion| {
1130                        CommitStatus {
1131                            state: conclusion.into(),
1132                            author: self.user.clone(),
1133                            refname: branch.clone(),
1134                            name,
1135                            description: summary.unwrap_or_default(),
1136                            target_url: details_url,
1137                        }
1138                    })
1139                })
1140            })
1141            .collect())
1142    }
1143
1144    fn post_commit_status(&self, status: PendingCommitStatus) -> Result<(), HostingServiceError> {
1145        self.post_check_run(status, None)
1146    }
1147
1148    fn post_review(
1149        &self,
1150        status: PendingCommitStatus,
1151        _: &MergeRequest,
1152        description: &str,
1153    ) -> Result<(), HostingServiceError> {
1154        self.post_check_run(status, Some(description.into()))
1155    }
1156
1157    fn get_mr_awards(&self, mr: &MergeRequest) -> Result<Vec<Award>, HostingServiceError> {
1158        let project = &mr.target_repo.name;
1159        let id = mr.id;
1160        let (owner, name) = Self::split_project(project)?;
1161
1162        let mut vars = queries::pull_request_reactions::Variables {
1163            owner: owner.into(),
1164            name: name.into(),
1165            pull: id as i64,
1166            cursor: None,
1167        };
1168
1169        let mut awards = Vec::new();
1170        loop {
1171            let query = queries::PullRequestReactions::build_query(vars.clone());
1172            let page_reactions = self
1173                .github
1174                .send::<queries::PullRequestReactions>(owner, &query)
1175                .map_err(HostingServiceError::host)
1176                .and_then(|rsp| {
1177                    Self::check_rate_limits(
1178                        &rsp.rate_limit_info.rate_limit,
1179                        queries::PullRequestReactions::name(),
1180                    );
1181                    Ok(rsp
1182                        .repository
1183                        .ok_or_else(|| GithubHostError::no_repository(project.clone()))?)
1184                })
1185                .and_then(|rsp| {
1186                    Ok(rsp
1187                        .pull_request
1188                        .ok_or_else(|| GithubHostError::no_pull(id, project.clone()))?)
1189                })?
1190                .reactions;
1191            let (reactions, page_info) = (
1192                page_reactions
1193                    .reactions
1194                    .ok_or_else(|| GithubHostError::no_pull_reaction_edges(id, project.into()))?,
1195                page_reactions.page_info,
1196            );
1197
1198            awards.extend(
1199                reactions.into_iter()
1200                    .filter_map(|reaction| {
1201                        reaction.and_then(|reaction| {
1202                            let queries::pull_request_reactions::PullRequestReactionsRepositoryPullRequestReactionsReactions {
1203                                content,
1204                                user,
1205                                ..
1206                            } = reaction;
1207
1208                            user.map(|user| {
1209                                Award {
1210                                    name: content.into(),
1211                                    author: user.into(),
1212                                }
1213                            })
1214                        })
1215                    })
1216            );
1217
1218            if page_info.has_next_page {
1219                // XXX: We are assuming that if `has_next_page` is `true` that we'll have an
1220                // `end_cursor`.
1221                assert!(
1222                    page_info.end_cursor.is_some(),
1223                    "GitHub gave us a new page without a cursor to follow.",
1224                );
1225                vars.cursor = page_info.end_cursor;
1226            } else {
1227                break;
1228            }
1229        }
1230
1231        Ok(awards)
1232    }
1233
1234    fn issues_closed_by_mr(&self, mr: &MergeRequest) -> Result<Vec<Issue>, HostingServiceError> {
1235        let project = &mr.target_repo.name;
1236        let id = mr.id;
1237        let (owner, name) = Self::split_project(project)?;
1238
1239        let mut vars = queries::issues_closed_by_pull_request::Variables {
1240            owner: owner.into(),
1241            name: name.into(),
1242            pull: id as i64,
1243            cursor: None,
1244        };
1245
1246        let mut issues: Vec<Issue> = Vec::new();
1247        loop {
1248            let query = queries::IssuesClosedByPullRequest::build_query(vars.clone());
1249            let page_issues = self
1250                .github
1251                .send::<queries::IssuesClosedByPullRequest>(owner, &query)
1252                .map_err(HostingServiceError::host)
1253                .and_then(|rsp| {
1254                    Self::check_rate_limits(
1255                        &rsp.rate_limit_info.rate_limit,
1256                        queries::IssuesClosedByPullRequest::name(),
1257                    );
1258                    Ok(rsp
1259                        .repository
1260                        .ok_or_else(|| GithubHostError::no_repository(project.clone()))?)
1261                })
1262                .and_then(|rsp| {
1263                    Ok(rsp
1264                        .pull_request
1265                        .ok_or_else(|| GithubHostError::no_pull(id, project.clone()))?)
1266                })
1267                .and_then(|rsp| {
1268                    Ok(rsp
1269                        .closing_issues_references
1270                        .ok_or_else(|| GithubHostError::no_closing_issues(id, project.clone()))?)
1271                })?;
1272            let (page_issues, page_info) = (
1273                page_issues.issues.ok_or_else(|| {
1274                    GithubHostError::no_issues_closed_by_pr_edges(id, project.into())
1275                })?,
1276                page_issues.page_info,
1277            );
1278
1279            issues.extend(
1280                page_issues.into_iter()
1281                    .filter_map(|issue| {
1282                        issue.and_then(|issue| {
1283                            let queries::issues_closed_by_pull_request::IssuesClosedByPullRequestRepositoryPullRequestClosingIssuesReferencesIssues {
1284                                repository,
1285                                number,
1286                                url,
1287                                labels,
1288                                ..
1289                            } = issue;
1290
1291                            let repo = self.repo(repository).ok()?;
1292                            let id = if number < 0 {
1293                                0
1294                            } else {
1295                                number as u64
1296                            };
1297                            let labels = labels
1298                                .and_then(|labels| labels.names)
1299                                .map(|names| {
1300                                    names.into_iter()
1301                                        .filter_map(|label| label.map(|label| label.name))
1302                                        .collect()
1303                                })
1304                                .unwrap_or_else(Vec::new);
1305
1306                            Some(Issue {
1307                                reference: format!("{}#{}", repo.name, number),
1308                                repo,
1309                                id,
1310                                url,
1311                                labels,
1312                            })
1313                        })
1314                    })
1315            );
1316
1317            if page_info.has_next_page {
1318                // XXX: We are assuming that if `has_next_page` is `true` that we'll have an
1319                // `end_cursor`.
1320                assert!(
1321                    page_info.end_cursor.is_some(),
1322                    "GitHub gave us a new page without a cursor to follow.",
1323                );
1324                vars.cursor = page_info.end_cursor;
1325            } else {
1326                break;
1327            }
1328        }
1329
1330        Ok(issues)
1331    }
1332
1333    fn add_issue_labels(&self, issue: &Issue, labels: &[&str]) -> Result<(), HostingServiceError> {
1334        let project = &issue.repo.name;
1335        let (owner, _) = Self::split_project(project)?;
1336        let (issue_id, label_ids) = self.issue_label_information(issue, labels)?;
1337
1338        let input = queries::add_issue_labels::Variables {
1339            input: queries::add_issue_labels::AddLabelsToLabelableInput {
1340                // TODO: Make a mutation ID.
1341                client_mutation_id: None,
1342                label_ids,
1343                labelable_id: issue_id,
1344            },
1345        };
1346        let mutation = queries::AddIssueLabels::build_query(input);
1347        self.github
1348            .send::<queries::AddIssueLabels>(owner, &mutation)
1349            .map_err(HostingServiceError::host)?;
1350
1351        Ok(())
1352    }
1353
1354    fn remove_issue_labels(
1355        &self,
1356        issue: &Issue,
1357        labels: &[&str],
1358    ) -> Result<(), HostingServiceError> {
1359        let project = &issue.repo.name;
1360        let (owner, _) = Self::split_project(project)?;
1361        let (issue_id, label_ids) = self.issue_label_information(issue, labels)?;
1362
1363        let input = queries::remove_issue_labels::Variables {
1364            input: queries::remove_issue_labels::RemoveLabelsFromLabelableInput {
1365                // TODO: Make a mutation ID.
1366                client_mutation_id: None,
1367                label_ids,
1368                labelable_id: issue_id,
1369            },
1370        };
1371        let mutation = queries::RemoveIssueLabels::build_query(input);
1372        self.github
1373            .send::<queries::RemoveIssueLabels>(owner, &mutation)
1374            .map_err(HostingServiceError::host)?;
1375
1376        Ok(())
1377    }
1378}
1379
1380impl Debug for GithubService {
1381    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1382        f.debug_struct("GithubService")
1383            .field("user", &self.user.handle)
1384            .finish()
1385    }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use std::iter;
1391
1392    use ghostflow::host::User;
1393
1394    use crate::authorization::CurrentUser;
1395
1396    #[test]
1397    fn test_current_user_conversion() {
1398        let expected_login = "login";
1399        let expected_email = "foo@bar.invalid";
1400        let expected_name = "name";
1401        let current_user = CurrentUser {
1402            login: expected_login.into(),
1403            email: expected_email.into(),
1404            name: expected_name.into(),
1405        };
1406
1407        let User {
1408            handle,
1409            email,
1410            name,
1411        } = current_user.into();
1412        assert_eq!(handle, expected_login);
1413        assert_eq!(email, expected_email);
1414        assert_eq!(name, expected_name);
1415    }
1416
1417    #[test]
1418    fn test_pr_reactions() {
1419        use crate::queries::pull_request_reactions::ReactionContent;
1420        let items = [
1421            (ReactionContent::CONFUSED, "confused"),
1422            (ReactionContent::EYES, "eyes"),
1423            (ReactionContent::HEART, "heart"),
1424            (ReactionContent::HOORAY, "hooray"),
1425            (ReactionContent::LAUGH, "laugh"),
1426            (ReactionContent::ROCKET, "rocket"),
1427            (ReactionContent::THUMBS_DOWN, "-1"),
1428            (ReactionContent::THUMBS_UP, "+1"),
1429            (ReactionContent::Other("blah".into()), "blah"),
1430        ];
1431
1432        for (r, s) in items {
1433            assert_eq!(String::from(r), s);
1434        }
1435    }
1436
1437    #[test]
1438    fn test_github_trim() {
1439        use super::{
1440            trim_to_check_run_limit, GITHUB_CHECK_RUN_MESSAGE_LIMIT, GITHUB_OVERFLOW_INDICATOR,
1441        };
1442
1443        let just_short_enough: String = iter::repeat_n(' ', GITHUB_CHECK_RUN_MESSAGE_LIMIT - 1)
1444            .chain(iter::once('0'))
1445            .collect();
1446        assert_eq!(just_short_enough.len(), GITHUB_CHECK_RUN_MESSAGE_LIMIT);
1447        let long_text: String = iter::repeat_n(' ', GITHUB_CHECK_RUN_MESSAGE_LIMIT)
1448            .chain(iter::once('0'))
1449            .collect();
1450        assert!(long_text.len() > GITHUB_CHECK_RUN_MESSAGE_LIMIT);
1451        let long_text_trimmed = format!(
1452            "{}{:width$}",
1453            GITHUB_OVERFLOW_INDICATOR,
1454            "", // the `0` will be overflowed out, so fill with blanks.
1455            width = GITHUB_CHECK_RUN_MESSAGE_LIMIT - GITHUB_OVERFLOW_INDICATOR.len(),
1456        );
1457        assert_eq!(long_text_trimmed.len(), GITHUB_CHECK_RUN_MESSAGE_LIMIT);
1458
1459        let cases = [
1460            ("", ""),
1461            ("short", "short"),
1462            (&just_short_enough, &just_short_enough),
1463            (&long_text, &long_text_trimmed),
1464        ];
1465
1466        for (input, expected) in cases {
1467            let actual = trim_to_check_run_limit(input.into());
1468            assert!(actual.len() <= GITHUB_CHECK_RUN_MESSAGE_LIMIT);
1469            assert_eq!(actual, expected);
1470        }
1471    }
1472}