checks.rs

  1use std::{fmt, ops::Not as _};
  2
  3use itertools::Itertools as _;
  4
  5use crate::{
  6    git::{CommitDetails, CommitList},
  7    github::{
  8        CommitAuthor, GitHubClient, GitHubUser, GithubLogin, PullRequestComment, PullRequestData,
  9        PullRequestReview, ReviewState,
 10    },
 11    report::Report,
 12};
 13
 14const ZED_ZIPPY_COMMENT_APPROVAL_PATTERN: &str = "@zed-zippy approve";
 15const ZED_ZIPPY_GROUP_APPROVAL: &str = "@zed-industries/approved";
 16
 17#[derive(Debug)]
 18pub enum ReviewSuccess {
 19    ApprovingComment(Vec<PullRequestComment>),
 20    CoAuthored(Vec<CommitAuthor>),
 21    ExternalMergedContribution { merged_by: GitHubUser },
 22    PullRequestReviewed(Vec<PullRequestReview>),
 23}
 24
 25impl ReviewSuccess {
 26    pub(crate) fn reviewers(&self) -> anyhow::Result<String> {
 27        let reviewers = match self {
 28            Self::CoAuthored(authors) => authors.iter().map(ToString::to_string).collect_vec(),
 29            Self::PullRequestReviewed(reviews) => reviews
 30                .iter()
 31                .filter_map(|review| review.user.as_ref())
 32                .map(|user| format!("@{}", user.login))
 33                .collect_vec(),
 34            Self::ApprovingComment(comments) => comments
 35                .iter()
 36                .map(|comment| format!("@{}", comment.user.login))
 37                .collect_vec(),
 38            Self::ExternalMergedContribution { merged_by } => {
 39                vec![format!("@{}", merged_by.login)]
 40            }
 41        };
 42
 43        let reviewers = reviewers.into_iter().unique().collect_vec();
 44
 45        reviewers
 46            .is_empty()
 47            .not()
 48            .then(|| reviewers.join(", "))
 49            .ok_or_else(|| anyhow::anyhow!("Expected at least one reviewer"))
 50    }
 51}
 52
 53impl fmt::Display for ReviewSuccess {
 54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
 55        match self {
 56            Self::CoAuthored(_) => formatter.write_str("Co-authored by an organization member"),
 57            Self::PullRequestReviewed(_) => {
 58                formatter.write_str("Approved by an organization review")
 59            }
 60            Self::ApprovingComment(_) => {
 61                formatter.write_str("Approved by an organization approval comment")
 62            }
 63            Self::ExternalMergedContribution { .. } => {
 64                formatter.write_str("External merged contribution")
 65            }
 66        }
 67    }
 68}
 69
 70#[derive(Debug)]
 71pub enum ReviewFailure {
 72    // todo: We could still query the GitHub API here to search for one
 73    NoPullRequestFound,
 74    Unreviewed,
 75    UnableToDetermineReviewer,
 76    Other(anyhow::Error),
 77}
 78
 79impl fmt::Display for ReviewFailure {
 80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
 81        match self {
 82            Self::NoPullRequestFound => formatter.write_str("No pull request found"),
 83            Self::Unreviewed => formatter
 84                .write_str("No qualifying organization approval found for the pull request"),
 85            Self::UnableToDetermineReviewer => formatter.write_str("Could not determine reviewer"),
 86            Self::Other(error) => write!(formatter, "Failed to inspect review state: {error}"),
 87        }
 88    }
 89}
 90
 91pub(crate) type ReviewResult = Result<ReviewSuccess, ReviewFailure>;
 92
 93impl<E: Into<anyhow::Error>> From<E> for ReviewFailure {
 94    fn from(err: E) -> Self {
 95        Self::Other(anyhow::anyhow!(err))
 96    }
 97}
 98
 99pub struct Reporter<'a> {
100    commits: CommitList,
101    github_client: &'a GitHubClient,
102}
103
104impl<'a> Reporter<'a> {
105    pub fn new(commits: CommitList, github_client: &'a GitHubClient) -> Self {
106        Self {
107            commits,
108            github_client,
109        }
110    }
111
112    /// Method that checks every commit for compliance
113    pub async fn check_commit(
114        &self,
115        commit: &CommitDetails,
116    ) -> Result<ReviewSuccess, ReviewFailure> {
117        let Some(pr_number) = commit.pr_number() else {
118            return Err(ReviewFailure::NoPullRequestFound);
119        };
120
121        let pull_request = self.github_client.get_pull_request(pr_number).await?;
122
123        if let Some(approval) = self
124            .check_approving_pull_request_review(&pull_request)
125            .await?
126        {
127            return Ok(approval);
128        }
129
130        if let Some(approval) = self
131            .check_approving_pull_request_comment(&pull_request)
132            .await?
133        {
134            return Ok(approval);
135        }
136
137        if let Some(approval) = self.check_commit_co_authors(commit).await? {
138            return Ok(approval);
139        }
140
141        // if let Some(approval) = self.check_external_merged_pr(pr_number).await? {
142        //     return Ok(approval);
143        // }
144
145        Err(ReviewFailure::Unreviewed)
146    }
147
148    async fn check_commit_co_authors(
149        &self,
150        commit: &CommitDetails,
151    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
152        if commit.co_authors().is_some()
153            && let Some(commit_authors) = self
154                .github_client
155                .get_commit_authors(&[commit.sha()])
156                .await?
157                .get(commit.sha())
158                .and_then(|authors| authors.co_authors())
159        {
160            let mut org_co_authors = Vec::new();
161            for co_author in commit_authors {
162                if let Some(github_login) = co_author.user()
163                    && self
164                        .github_client
165                        .actor_has_repository_write_permission(github_login)
166                        .await?
167                {
168                    org_co_authors.push(co_author.clone());
169                }
170            }
171
172            Ok(org_co_authors
173                .is_empty()
174                .not()
175                .then_some(ReviewSuccess::CoAuthored(org_co_authors)))
176        } else {
177            Ok(None)
178        }
179    }
180
181    #[allow(unused)]
182    async fn check_external_merged_pr(
183        &self,
184        pull_request: PullRequestData,
185    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
186        if let Some(user) = pull_request.user
187            && self
188                .github_client
189                .actor_has_repository_write_permission(&GithubLogin::new(user.login))
190                .await?
191                .not()
192        {
193            pull_request.merged_by.map_or(
194                Err(ReviewFailure::UnableToDetermineReviewer),
195                |merged_by| {
196                    Ok(Some(ReviewSuccess::ExternalMergedContribution {
197                        merged_by,
198                    }))
199                },
200            )
201        } else {
202            Ok(None)
203        }
204    }
205
206    async fn check_approving_pull_request_review(
207        &self,
208        pull_request: &PullRequestData,
209    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
210        let pr_reviews = self
211            .github_client
212            .get_pull_request_reviews(pull_request.number)
213            .await?;
214
215        if !pr_reviews.is_empty() {
216            let mut org_approving_reviews = Vec::new();
217            for review in pr_reviews {
218                if let Some(github_login) = review.user.as_ref()
219                    && pull_request
220                        .user
221                        .as_ref()
222                        .is_none_or(|pr_user| pr_user.login != github_login.login)
223                    && (review
224                        .state
225                        .is_some_and(|state| state == ReviewState::Approved)
226                        || review
227                            .body
228                            .as_deref()
229                            .is_some_and(Self::contains_approving_pattern))
230                    && self
231                        .github_client
232                        .actor_has_repository_write_permission(&GithubLogin::new(
233                            github_login.login.clone(),
234                        ))
235                        .await?
236                {
237                    org_approving_reviews.push(review);
238                }
239            }
240
241            Ok(org_approving_reviews
242                .is_empty()
243                .not()
244                .then_some(ReviewSuccess::PullRequestReviewed(org_approving_reviews)))
245        } else {
246            Ok(None)
247        }
248    }
249
250    async fn check_approving_pull_request_comment(
251        &self,
252        pull_request: &PullRequestData,
253    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
254        let other_comments = self
255            .github_client
256            .get_pull_request_comments(pull_request.number)
257            .await?;
258
259        if !other_comments.is_empty() {
260            let mut org_approving_comments = Vec::new();
261
262            for comment in other_comments {
263                if pull_request
264                    .user
265                    .as_ref()
266                    .is_some_and(|pr_author| pr_author.login != comment.user.login)
267                    && comment
268                        .body
269                        .as_deref()
270                        .is_some_and(Self::contains_approving_pattern)
271                    && self
272                        .github_client
273                        .actor_has_repository_write_permission(&GithubLogin::new(
274                            comment.user.login.clone(),
275                        ))
276                        .await?
277                {
278                    org_approving_comments.push(comment);
279                }
280            }
281
282            Ok(org_approving_comments
283                .is_empty()
284                .not()
285                .then_some(ReviewSuccess::ApprovingComment(org_approving_comments)))
286        } else {
287            Ok(None)
288        }
289    }
290
291    fn contains_approving_pattern(body: &str) -> bool {
292        body.contains(ZED_ZIPPY_COMMENT_APPROVAL_PATTERN) || body.contains(ZED_ZIPPY_GROUP_APPROVAL)
293    }
294
295    pub async fn generate_report(mut self) -> anyhow::Result<Report> {
296        let mut report = Report::new();
297
298        let commits_to_check = std::mem::take(&mut self.commits);
299        let total_commits = commits_to_check.len();
300
301        for (i, commit) in commits_to_check.into_iter().enumerate() {
302            println!(
303                "Checking commit {:?} ({current}/{total})",
304                commit.sha().short(),
305                current = i + 1,
306                total = total_commits
307            );
308
309            let review_result = self.check_commit(&commit).await;
310
311            if let Err(err) = &review_result {
312                println!("Commit {:?} failed review: {:?}", commit.sha().short(), err);
313            }
314
315            report.add(commit, review_result);
316        }
317
318        Ok(report)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use std::rc::Rc;
325    use std::str::FromStr;
326
327    use crate::git::{CommitDetails, CommitList, CommitSha};
328    use crate::github::{
329        AuthorsForCommits, GitHubApiClient, GitHubClient, GitHubUser, GithubLogin,
330        PullRequestComment, PullRequestData, PullRequestReview, ReviewState,
331    };
332
333    use super::{Reporter, ReviewFailure, ReviewSuccess};
334
335    struct MockGitHubApi {
336        pull_request: PullRequestData,
337        reviews: Vec<PullRequestReview>,
338        comments: Vec<PullRequestComment>,
339        commit_authors_json: serde_json::Value,
340        org_members: Vec<String>,
341    }
342
343    #[async_trait::async_trait(?Send)]
344    impl GitHubApiClient for MockGitHubApi {
345        async fn get_pull_request(&self, _pr_number: u64) -> anyhow::Result<PullRequestData> {
346            Ok(self.pull_request.clone())
347        }
348
349        async fn get_pull_request_reviews(
350            &self,
351            _pr_number: u64,
352        ) -> anyhow::Result<Vec<PullRequestReview>> {
353            Ok(self.reviews.clone())
354        }
355
356        async fn get_pull_request_comments(
357            &self,
358            _pr_number: u64,
359        ) -> anyhow::Result<Vec<PullRequestComment>> {
360            Ok(self.comments.clone())
361        }
362
363        async fn get_commit_authors(
364            &self,
365            _commit_shas: &[&CommitSha],
366        ) -> anyhow::Result<AuthorsForCommits> {
367            serde_json::from_value(self.commit_authors_json.clone()).map_err(Into::into)
368        }
369
370        async fn check_org_membership(&self, login: &GithubLogin) -> anyhow::Result<bool> {
371            Ok(self
372                .org_members
373                .iter()
374                .any(|member| member == login.as_str()))
375        }
376
377        async fn check_repo_write_permission(&self, _login: &GithubLogin) -> anyhow::Result<bool> {
378            Ok(false)
379        }
380
381        async fn add_label_to_issue(&self, _label: &str, _pr_number: u64) -> anyhow::Result<()> {
382            Ok(())
383        }
384    }
385
386    fn make_commit(
387        sha: &str,
388        author_name: &str,
389        author_email: &str,
390        title: &str,
391        body: &str,
392    ) -> CommitDetails {
393        let formatted = format!(
394            "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|\
395             {title}|body-delimiter|{body}|commit-delimiter|"
396        );
397        CommitList::from_str(&formatted)
398            .expect("test commit should parse")
399            .into_iter()
400            .next()
401            .expect("should have one commit")
402    }
403
404    fn review(login: &str, state: ReviewState) -> PullRequestReview {
405        PullRequestReview {
406            user: Some(GitHubUser {
407                login: login.to_owned(),
408            }),
409            state: Some(state),
410            body: None,
411        }
412    }
413
414    fn comment(login: &str, body: &str) -> PullRequestComment {
415        PullRequestComment {
416            user: GitHubUser {
417                login: login.to_owned(),
418            },
419            body: Some(body.to_owned()),
420        }
421    }
422
423    struct TestScenario {
424        pull_request: PullRequestData,
425        reviews: Vec<PullRequestReview>,
426        comments: Vec<PullRequestComment>,
427        commit_authors_json: serde_json::Value,
428        org_members: Vec<String>,
429        commit: CommitDetails,
430    }
431
432    impl TestScenario {
433        fn single_commit() -> Self {
434            Self {
435                pull_request: PullRequestData {
436                    number: 1234,
437                    user: Some(GitHubUser {
438                        login: "alice".to_owned(),
439                    }),
440                    merged_by: None,
441                    labels: None,
442                },
443                reviews: vec![],
444                comments: vec![],
445                commit_authors_json: serde_json::json!({}),
446                org_members: vec![],
447                commit: make_commit(
448                    "abc12345abc12345",
449                    "Alice",
450                    "alice@test.com",
451                    "Fix thing (#1234)",
452                    "",
453                ),
454            }
455        }
456
457        fn with_reviews(mut self, reviews: Vec<PullRequestReview>) -> Self {
458            self.reviews = reviews;
459            self
460        }
461
462        fn with_comments(mut self, comments: Vec<PullRequestComment>) -> Self {
463            self.comments = comments;
464            self
465        }
466
467        fn with_org_members(mut self, members: Vec<&str>) -> Self {
468            self.org_members = members.into_iter().map(str::to_owned).collect();
469            self
470        }
471
472        fn with_commit_authors_json(mut self, json: serde_json::Value) -> Self {
473            self.commit_authors_json = json;
474            self
475        }
476
477        fn with_commit(mut self, commit: CommitDetails) -> Self {
478            self.commit = commit;
479            self
480        }
481
482        async fn run_scenario(self) -> Result<ReviewSuccess, ReviewFailure> {
483            let mock = MockGitHubApi {
484                pull_request: self.pull_request,
485                reviews: self.reviews,
486                comments: self.comments,
487                commit_authors_json: self.commit_authors_json,
488                org_members: self.org_members,
489            };
490            let client = GitHubClient::new(Rc::new(mock));
491            let reporter = Reporter::new(CommitList::default(), &client);
492            reporter.check_commit(&self.commit).await
493        }
494    }
495
496    #[tokio::test]
497    async fn approved_review_by_org_member_succeeds() {
498        let result = TestScenario::single_commit()
499            .with_reviews(vec![review("bob", ReviewState::Approved)])
500            .with_org_members(vec!["bob"])
501            .run_scenario()
502            .await;
503        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
504    }
505
506    #[tokio::test]
507    async fn non_approved_review_state_is_not_accepted() {
508        let result = TestScenario::single_commit()
509            .with_reviews(vec![review("bob", ReviewState::Other)])
510            .with_org_members(vec!["bob"])
511            .run_scenario()
512            .await;
513        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
514    }
515
516    #[tokio::test]
517    async fn review_by_non_org_member_is_not_accepted() {
518        let result = TestScenario::single_commit()
519            .with_reviews(vec![review("bob", ReviewState::Approved)])
520            .run_scenario()
521            .await;
522        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
523    }
524
525    #[tokio::test]
526    async fn pr_author_own_approval_review_is_rejected() {
527        let result = TestScenario::single_commit()
528            .with_reviews(vec![review("alice", ReviewState::Approved)])
529            .with_org_members(vec!["alice"])
530            .run_scenario()
531            .await;
532        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
533    }
534
535    #[tokio::test]
536    async fn pr_author_own_approval_comment_is_rejected() {
537        let result = TestScenario::single_commit()
538            .with_comments(vec![comment("alice", "@zed-zippy approve")])
539            .with_org_members(vec!["alice"])
540            .run_scenario()
541            .await;
542        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
543    }
544
545    #[tokio::test]
546    async fn approval_comment_by_org_member_succeeds() {
547        let result = TestScenario::single_commit()
548            .with_comments(vec![comment("bob", "@zed-zippy approve")])
549            .with_org_members(vec!["bob"])
550            .run_scenario()
551            .await;
552        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
553    }
554
555    #[tokio::test]
556    async fn group_approval_comment_by_org_member_succeeds() {
557        let result = TestScenario::single_commit()
558            .with_comments(vec![comment("bob", "@zed-industries/approved")])
559            .with_org_members(vec!["bob"])
560            .run_scenario()
561            .await;
562        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
563    }
564
565    #[tokio::test]
566    async fn comment_without_approval_pattern_is_not_accepted() {
567        let result = TestScenario::single_commit()
568            .with_comments(vec![comment("bob", "looks good")])
569            .with_org_members(vec!["bob"])
570            .run_scenario()
571            .await;
572        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
573    }
574
575    #[tokio::test]
576    async fn commit_without_pr_number_is_no_pr_found() {
577        let result = TestScenario::single_commit()
578            .with_commit(make_commit(
579                "abc12345abc12345",
580                "Alice",
581                "alice@test.com",
582                "Fix thing without PR number",
583                "",
584            ))
585            .run_scenario()
586            .await;
587        assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
588    }
589
590    #[tokio::test]
591    async fn pr_review_takes_precedence_over_comment() {
592        let result = TestScenario::single_commit()
593            .with_reviews(vec![review("bob", ReviewState::Approved)])
594            .with_comments(vec![comment("charlie", "@zed-zippy approve")])
595            .with_org_members(vec!["bob", "charlie"])
596            .run_scenario()
597            .await;
598        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
599    }
600
601    #[tokio::test]
602    async fn comment_takes_precedence_over_co_author() {
603        let result = TestScenario::single_commit()
604            .with_comments(vec![comment("bob", "@zed-zippy approve")])
605            .with_commit_authors_json(serde_json::json!({
606                "abc12345abc12345": {
607                    "author": {
608                        "name": "Alice",
609                        "email": "alice@test.com",
610                        "user": { "login": "alice" }
611                    },
612                    "authors": { "nodes": [{
613                        "name": "Charlie",
614                        "email": "charlie@test.com",
615                        "user": { "login": "charlie" }
616                    }] }
617                }
618            }))
619            .with_commit(make_commit(
620                "abc12345abc12345",
621                "Alice",
622                "alice@test.com",
623                "Fix thing (#1234)",
624                "Co-authored-by: Charlie <charlie@test.com>",
625            ))
626            .with_org_members(vec!["bob", "charlie"])
627            .run_scenario()
628            .await;
629        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
630    }
631
632    #[tokio::test]
633    async fn co_author_org_member_succeeds() {
634        let result = TestScenario::single_commit()
635            .with_commit_authors_json(serde_json::json!({
636                "abc12345abc12345": {
637                    "author": {
638                        "name": "Alice",
639                        "email": "alice@test.com",
640                        "user": { "login": "alice" }
641                    },
642                    "authors": { "nodes": [{
643                        "name": "Bob",
644                        "email": "bob@test.com",
645                        "user": { "login": "bob" }
646                    }] }
647                }
648            }))
649            .with_commit(make_commit(
650                "abc12345abc12345",
651                "Alice",
652                "alice@test.com",
653                "Fix thing (#1234)",
654                "Co-authored-by: Bob <bob@test.com>",
655            ))
656            .with_org_members(vec!["bob"])
657            .run_scenario()
658            .await;
659        assert!(matches!(result, Ok(ReviewSuccess::CoAuthored(_))));
660    }
661
662    #[tokio::test]
663    async fn no_reviews_no_comments_no_coauthors_is_unreviewed() {
664        let result = TestScenario::single_commit().run_scenario().await;
665        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
666    }
667
668    #[tokio::test]
669    async fn review_with_zippy_approval_body_is_accepted() {
670        let result = TestScenario::single_commit()
671            .with_reviews(vec![
672                review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
673            ])
674            .with_org_members(vec!["bob"])
675            .run_scenario()
676            .await;
677        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
678    }
679
680    #[tokio::test]
681    async fn review_with_group_approval_body_is_accepted() {
682        let result = TestScenario::single_commit()
683            .with_reviews(vec![
684                review("bob", ReviewState::Other).with_body("@zed-industries/approved"),
685            ])
686            .with_org_members(vec!["bob"])
687            .run_scenario()
688            .await;
689        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
690    }
691
692    #[tokio::test]
693    async fn review_with_non_approving_body_is_not_accepted() {
694        let result = TestScenario::single_commit()
695            .with_reviews(vec![
696                review("bob", ReviewState::Other).with_body("looks good to me"),
697            ])
698            .with_org_members(vec!["bob"])
699            .run_scenario()
700            .await;
701        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
702    }
703
704    #[tokio::test]
705    async fn review_with_approving_body_from_external_user_is_not_accepted() {
706        let result = TestScenario::single_commit()
707            .with_reviews(vec![
708                review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
709            ])
710            .run_scenario()
711            .await;
712        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
713    }
714
715    #[tokio::test]
716    async fn review_with_approving_body_from_pr_author_is_rejected() {
717        let result = TestScenario::single_commit()
718            .with_reviews(vec![
719                review("alice", ReviewState::Other).with_body("@zed-zippy approve"),
720            ])
721            .with_org_members(vec!["alice"])
722            .run_scenario()
723            .await;
724        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
725    }
726}