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