checks.rs

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