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, GithubLogin, GithubUser, PullRequestComment, PullRequestData,
  9        PullRequestReview, Repository, 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
122            .github_client
123            .get_pull_request(&Repository::ZED, pr_number)
124            .await?;
125
126        if let Some(approval) = self
127            .check_approving_pull_request_review(&pull_request)
128            .await?
129        {
130            return Ok(approval);
131        }
132
133        if let Some(approval) = self
134            .check_approving_pull_request_comment(&pull_request)
135            .await?
136        {
137            return Ok(approval);
138        }
139
140        if let Some(approval) = self.check_commit_co_authors(commit).await? {
141            return Ok(approval);
142        }
143
144        // if let Some(approval) = self.check_external_merged_pr(pr_number).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    #[allow(unused)]
185    async fn check_external_merged_pr(
186        &self,
187        pull_request: PullRequestData,
188    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
189        if let Some(user) = pull_request.user
190            && self
191                .github_client
192                .check_repo_write_permission(&Repository::ZED, &GithubLogin::new(user.login))
193                .await?
194                .not()
195        {
196            pull_request.merged_by.map_or(
197                Err(ReviewFailure::UnableToDetermineReviewer),
198                |merged_by| {
199                    Ok(Some(ReviewSuccess::ExternalMergedContribution {
200                        merged_by,
201                    }))
202                },
203            )
204        } else {
205            Ok(None)
206        }
207    }
208
209    async fn check_approving_pull_request_review(
210        &self,
211        pull_request: &PullRequestData,
212    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
213        let pr_reviews = self
214            .github_client
215            .get_pull_request_reviews(&Repository::ZED, pull_request.number)
216            .await?;
217
218        if !pr_reviews.is_empty() {
219            let mut org_approving_reviews = Vec::new();
220            for review in pr_reviews {
221                if let Some(github_login) = review.user.as_ref()
222                    && pull_request
223                        .user
224                        .as_ref()
225                        .is_none_or(|pr_user| pr_user.login != github_login.login)
226                    && (review
227                        .state
228                        .is_some_and(|state| state == ReviewState::Approved)
229                        || review
230                            .body
231                            .as_deref()
232                            .is_some_and(Self::contains_approving_pattern))
233                    && self
234                        .github_client
235                        .check_repo_write_permission(
236                            &Repository::ZED,
237                            &GithubLogin::new(github_login.login.clone()),
238                        )
239                        .await?
240                {
241                    org_approving_reviews.push(review);
242                }
243            }
244
245            Ok(org_approving_reviews
246                .is_empty()
247                .not()
248                .then_some(ReviewSuccess::PullRequestReviewed(org_approving_reviews)))
249        } else {
250            Ok(None)
251        }
252    }
253
254    async fn check_approving_pull_request_comment(
255        &self,
256        pull_request: &PullRequestData,
257    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
258        let other_comments = self
259            .github_client
260            .get_pull_request_comments(&Repository::ZED, pull_request.number)
261            .await?;
262
263        if !other_comments.is_empty() {
264            let mut org_approving_comments = Vec::new();
265
266            for comment in other_comments {
267                if pull_request
268                    .user
269                    .as_ref()
270                    .is_some_and(|pr_author| pr_author.login != comment.user.login)
271                    && comment
272                        .body
273                        .as_deref()
274                        .is_some_and(Self::contains_approving_pattern)
275                    && self
276                        .github_client
277                        .check_repo_write_permission(
278                            &Repository::ZED,
279                            &GithubLogin::new(comment.user.login.clone()),
280                        )
281                        .await?
282                {
283                    org_approving_comments.push(comment);
284                }
285            }
286
287            Ok(org_approving_comments
288                .is_empty()
289                .not()
290                .then_some(ReviewSuccess::ApprovingComment(org_approving_comments)))
291        } else {
292            Ok(None)
293        }
294    }
295
296    fn contains_approving_pattern(body: &str) -> bool {
297        body.contains(ZED_ZIPPY_COMMENT_APPROVAL_PATTERN) || body.contains(ZED_ZIPPY_GROUP_APPROVAL)
298    }
299
300    pub async fn generate_report(mut self) -> anyhow::Result<Report> {
301        let mut report = Report::new();
302
303        let commits_to_check = std::mem::take(&mut self.commits);
304        let total_commits = commits_to_check.len();
305
306        for (i, commit) in commits_to_check.into_iter().enumerate() {
307            println!(
308                "Checking commit {:?} ({current}/{total})",
309                commit.sha().short(),
310                current = i + 1,
311                total = total_commits
312            );
313
314            let review_result = self.check_commit(&commit).await;
315
316            if let Err(err) = &review_result {
317                println!("Commit {:?} failed review: {:?}", commit.sha().short(), err);
318            }
319
320            report.add(commit, review_result);
321        }
322
323        Ok(report)
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use std::rc::Rc;
330    use std::str::FromStr;
331
332    use crate::git::{CommitDetails, CommitList, CommitSha};
333    use crate::github::{
334        AuthorsForCommits, GithubApiClient, GithubClient, GithubLogin, GithubUser,
335        PullRequestComment, PullRequestData, PullRequestReview, Repository, ReviewState,
336    };
337
338    use super::{Reporter, ReviewFailure, ReviewSuccess};
339
340    struct MockGithubApi {
341        pull_request: PullRequestData,
342        reviews: Vec<PullRequestReview>,
343        comments: Vec<PullRequestComment>,
344        commit_authors_json: serde_json::Value,
345        org_members: Vec<String>,
346    }
347
348    #[async_trait::async_trait(?Send)]
349    impl GithubApiClient for MockGithubApi {
350        async fn get_pull_request(
351            &self,
352            _repo: &Repository<'_>,
353            _pr_number: u64,
354        ) -> anyhow::Result<PullRequestData> {
355            Ok(self.pull_request.clone())
356        }
357
358        async fn get_pull_request_reviews(
359            &self,
360            _repo: &Repository<'_>,
361            _pr_number: u64,
362        ) -> anyhow::Result<Vec<PullRequestReview>> {
363            Ok(self.reviews.clone())
364        }
365
366        async fn get_pull_request_comments(
367            &self,
368            _repo: &Repository<'_>,
369            _pr_number: u64,
370        ) -> anyhow::Result<Vec<PullRequestComment>> {
371            Ok(self.comments.clone())
372        }
373
374        async fn get_commit_authors(
375            &self,
376            _repo: &Repository<'_>,
377            _commit_shas: &[&CommitSha],
378        ) -> anyhow::Result<AuthorsForCommits> {
379            serde_json::from_value(self.commit_authors_json.clone()).map_err(Into::into)
380        }
381
382        async fn check_repo_write_permission(
383            &self,
384            _repo: &Repository<'_>,
385            login: &GithubLogin,
386        ) -> anyhow::Result<bool> {
387            Ok(self
388                .org_members
389                .iter()
390                .any(|member| member == login.as_str()))
391        }
392
393        async fn add_label_to_issue(
394            &self,
395            _repo: &Repository<'_>,
396            _label: &str,
397            _pr_number: u64,
398        ) -> anyhow::Result<()> {
399            Ok(())
400        }
401    }
402
403    fn make_commit(
404        sha: &str,
405        author_name: &str,
406        author_email: &str,
407        title: &str,
408        body: &str,
409    ) -> CommitDetails {
410        let formatted = format!(
411            "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|\
412             {title}|body-delimiter|{body}|commit-delimiter|"
413        );
414        CommitList::from_str(&formatted)
415            .expect("test commit should parse")
416            .into_iter()
417            .next()
418            .expect("should have one commit")
419    }
420
421    fn review(login: &str, state: ReviewState) -> PullRequestReview {
422        PullRequestReview {
423            user: Some(GithubUser {
424                login: login.to_owned(),
425            }),
426            state: Some(state),
427            body: None,
428        }
429    }
430
431    fn comment(login: &str, body: &str) -> PullRequestComment {
432        PullRequestComment {
433            user: GithubUser {
434                login: login.to_owned(),
435            },
436            body: Some(body.to_owned()),
437        }
438    }
439
440    struct TestScenario {
441        pull_request: PullRequestData,
442        reviews: Vec<PullRequestReview>,
443        comments: Vec<PullRequestComment>,
444        commit_authors_json: serde_json::Value,
445        org_members: Vec<String>,
446        commit: CommitDetails,
447    }
448
449    impl TestScenario {
450        fn single_commit() -> Self {
451            Self {
452                pull_request: PullRequestData {
453                    number: 1234,
454                    user: Some(GithubUser {
455                        login: "alice".to_owned(),
456                    }),
457                    merged_by: None,
458                    labels: None,
459                },
460                reviews: vec![],
461                comments: vec![],
462                commit_authors_json: serde_json::json!({}),
463                org_members: vec![],
464                commit: make_commit(
465                    "abc12345abc12345",
466                    "Alice",
467                    "alice@test.com",
468                    "Fix thing (#1234)",
469                    "",
470                ),
471            }
472        }
473
474        fn with_reviews(mut self, reviews: Vec<PullRequestReview>) -> Self {
475            self.reviews = reviews;
476            self
477        }
478
479        fn with_comments(mut self, comments: Vec<PullRequestComment>) -> Self {
480            self.comments = comments;
481            self
482        }
483
484        fn with_org_members(mut self, members: Vec<&str>) -> Self {
485            self.org_members = members.into_iter().map(str::to_owned).collect();
486            self
487        }
488
489        fn with_commit_authors_json(mut self, json: serde_json::Value) -> Self {
490            self.commit_authors_json = json;
491            self
492        }
493
494        fn with_commit(mut self, commit: CommitDetails) -> Self {
495            self.commit = commit;
496            self
497        }
498
499        async fn run_scenario(self) -> Result<ReviewSuccess, ReviewFailure> {
500            let mock = MockGithubApi {
501                pull_request: self.pull_request,
502                reviews: self.reviews,
503                comments: self.comments,
504                commit_authors_json: self.commit_authors_json,
505                org_members: self.org_members,
506            };
507            let client = GithubClient::new(Rc::new(mock));
508            let reporter = Reporter::new(CommitList::default(), &client);
509            reporter.check_commit(&self.commit).await
510        }
511    }
512
513    #[tokio::test]
514    async fn approved_review_by_org_member_succeeds() {
515        let result = TestScenario::single_commit()
516            .with_reviews(vec![review("bob", ReviewState::Approved)])
517            .with_org_members(vec!["bob"])
518            .run_scenario()
519            .await;
520        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
521    }
522
523    #[tokio::test]
524    async fn non_approved_review_state_is_not_accepted() {
525        let result = TestScenario::single_commit()
526            .with_reviews(vec![review("bob", ReviewState::Other)])
527            .with_org_members(vec!["bob"])
528            .run_scenario()
529            .await;
530        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
531    }
532
533    #[tokio::test]
534    async fn review_by_non_org_member_is_not_accepted() {
535        let result = TestScenario::single_commit()
536            .with_reviews(vec![review("bob", ReviewState::Approved)])
537            .run_scenario()
538            .await;
539        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
540    }
541
542    #[tokio::test]
543    async fn pr_author_own_approval_review_is_rejected() {
544        let result = TestScenario::single_commit()
545            .with_reviews(vec![review("alice", ReviewState::Approved)])
546            .with_org_members(vec!["alice"])
547            .run_scenario()
548            .await;
549        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
550    }
551
552    #[tokio::test]
553    async fn pr_author_own_approval_comment_is_rejected() {
554        let result = TestScenario::single_commit()
555            .with_comments(vec![comment("alice", "@zed-zippy approve")])
556            .with_org_members(vec!["alice"])
557            .run_scenario()
558            .await;
559        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
560    }
561
562    #[tokio::test]
563    async fn approval_comment_by_org_member_succeeds() {
564        let result = TestScenario::single_commit()
565            .with_comments(vec![comment("bob", "@zed-zippy approve")])
566            .with_org_members(vec!["bob"])
567            .run_scenario()
568            .await;
569        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
570    }
571
572    #[tokio::test]
573    async fn group_approval_comment_by_org_member_succeeds() {
574        let result = TestScenario::single_commit()
575            .with_comments(vec![comment("bob", "@zed-industries/approved")])
576            .with_org_members(vec!["bob"])
577            .run_scenario()
578            .await;
579        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
580    }
581
582    #[tokio::test]
583    async fn comment_without_approval_pattern_is_not_accepted() {
584        let result = TestScenario::single_commit()
585            .with_comments(vec![comment("bob", "looks good")])
586            .with_org_members(vec!["bob"])
587            .run_scenario()
588            .await;
589        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
590    }
591
592    #[tokio::test]
593    async fn commit_without_pr_number_is_no_pr_found() {
594        let result = TestScenario::single_commit()
595            .with_commit(make_commit(
596                "abc12345abc12345",
597                "Alice",
598                "alice@test.com",
599                "Fix thing without PR number",
600                "",
601            ))
602            .run_scenario()
603            .await;
604        assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
605    }
606
607    #[tokio::test]
608    async fn pr_review_takes_precedence_over_comment() {
609        let result = TestScenario::single_commit()
610            .with_reviews(vec![review("bob", ReviewState::Approved)])
611            .with_comments(vec![comment("charlie", "@zed-zippy approve")])
612            .with_org_members(vec!["bob", "charlie"])
613            .run_scenario()
614            .await;
615        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
616    }
617
618    #[tokio::test]
619    async fn comment_takes_precedence_over_co_author() {
620        let result = TestScenario::single_commit()
621            .with_comments(vec![comment("bob", "@zed-zippy approve")])
622            .with_commit_authors_json(serde_json::json!({
623                "abc12345abc12345": {
624                    "author": {
625                        "name": "Alice",
626                        "email": "alice@test.com",
627                        "user": { "login": "alice" }
628                    },
629                    "authors": { "nodes": [{
630                        "name": "Charlie",
631                        "email": "charlie@test.com",
632                        "user": { "login": "charlie" }
633                    }] }
634                }
635            }))
636            .with_commit(make_commit(
637                "abc12345abc12345",
638                "Alice",
639                "alice@test.com",
640                "Fix thing (#1234)",
641                "Co-authored-by: Charlie <charlie@test.com>",
642            ))
643            .with_org_members(vec!["bob", "charlie"])
644            .run_scenario()
645            .await;
646        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
647    }
648
649    #[tokio::test]
650    async fn co_author_org_member_succeeds() {
651        let result = TestScenario::single_commit()
652            .with_commit_authors_json(serde_json::json!({
653                "abc12345abc12345": {
654                    "author": {
655                        "name": "Alice",
656                        "email": "alice@test.com",
657                        "user": { "login": "alice" }
658                    },
659                    "authors": { "nodes": [{
660                        "name": "Bob",
661                        "email": "bob@test.com",
662                        "user": { "login": "bob" }
663                    }] }
664                }
665            }))
666            .with_commit(make_commit(
667                "abc12345abc12345",
668                "Alice",
669                "alice@test.com",
670                "Fix thing (#1234)",
671                "Co-authored-by: Bob <bob@test.com>",
672            ))
673            .with_org_members(vec!["bob"])
674            .run_scenario()
675            .await;
676        assert!(matches!(result, Ok(ReviewSuccess::CoAuthored(_))));
677    }
678
679    #[tokio::test]
680    async fn no_reviews_no_comments_no_coauthors_is_unreviewed() {
681        let result = TestScenario::single_commit().run_scenario().await;
682        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
683    }
684
685    #[tokio::test]
686    async fn review_with_zippy_approval_body_is_accepted() {
687        let result = TestScenario::single_commit()
688            .with_reviews(vec![
689                review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
690            ])
691            .with_org_members(vec!["bob"])
692            .run_scenario()
693            .await;
694        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
695    }
696
697    #[tokio::test]
698    async fn review_with_group_approval_body_is_accepted() {
699        let result = TestScenario::single_commit()
700            .with_reviews(vec![
701                review("bob", ReviewState::Other).with_body("@zed-industries/approved"),
702            ])
703            .with_org_members(vec!["bob"])
704            .run_scenario()
705            .await;
706        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
707    }
708
709    #[tokio::test]
710    async fn review_with_non_approving_body_is_not_accepted() {
711        let result = TestScenario::single_commit()
712            .with_reviews(vec![
713                review("bob", ReviewState::Other).with_body("looks good to me"),
714            ])
715            .with_org_members(vec!["bob"])
716            .run_scenario()
717            .await;
718        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
719    }
720
721    #[tokio::test]
722    async fn review_with_approving_body_from_external_user_is_not_accepted() {
723        let result = TestScenario::single_commit()
724            .with_reviews(vec![
725                review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
726            ])
727            .run_scenario()
728            .await;
729        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
730    }
731
732    #[tokio::test]
733    async fn review_with_approving_body_from_pr_author_is_rejected() {
734        let result = TestScenario::single_commit()
735            .with_reviews(vec![
736                review("alice", ReviewState::Other).with_body("@zed-zippy approve"),
737            ])
738            .with_org_members(vec!["alice"])
739            .run_scenario()
740            .await;
741        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
742    }
743}