1#![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 } = user;
68
69 Self {
70 name: name.unwrap_or_else(|| login.clone()),
71 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 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 .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 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 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 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
205fn 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
232pub struct GithubService {
248 github: Github,
250 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 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 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 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 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 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 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 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 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 Ok(())
494 }
495
496 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 pub fn github(&self) -> &Github {
508 &self.github
509 }
510
511 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 if user.ends_with("[bot]") {
743 return Ok(User {
744 handle: user.into(),
745 name: user.into(),
746 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 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 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 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 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 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 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 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 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 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 "", 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}