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