checks.rs

   1use std::{fmt, ops::Not as _, rc::Rc};
   2
   3use itertools::Itertools as _;
   4
   5use crate::{
   6    git::{CommitDetails, CommitList, ZED_ZIPPY_LOGIN},
   7    github::{
   8        CommitAuthor, GithubApiClient, 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";
  16const EXPECTED_VERSION_BUMP_LOC: u64 = 2;
  17
  18#[derive(Debug)]
  19pub enum ReviewSuccess {
  20    ApprovingComment(Vec<PullRequestComment>),
  21    CoAuthored(Vec<CommitAuthor>),
  22    PullRequestReviewed(Vec<PullRequestReview>),
  23    ZedZippyCommit(GithubLogin),
  24}
  25
  26impl ReviewSuccess {
  27    pub(crate) fn reviewers(&self) -> anyhow::Result<String> {
  28        let reviewers = match self {
  29            Self::CoAuthored(authors) => authors.iter().map(ToString::to_string).collect_vec(),
  30            Self::PullRequestReviewed(reviews) => reviews
  31                .iter()
  32                .filter_map(|review| review.user.as_ref())
  33                .map(|user| format!("@{}", user.login))
  34                .collect_vec(),
  35            Self::ApprovingComment(comments) => comments
  36                .iter()
  37                .map(|comment| format!("@{}", comment.user.login))
  38                .collect_vec(),
  39            Self::ZedZippyCommit(login) => vec![login.to_string()],
  40        };
  41
  42        let reviewers = reviewers.into_iter().unique().collect_vec();
  43
  44        reviewers
  45            .is_empty()
  46            .not()
  47            .then(|| reviewers.join(", "))
  48            .ok_or_else(|| anyhow::anyhow!("Expected at least one reviewer"))
  49    }
  50}
  51
  52impl fmt::Display for ReviewSuccess {
  53    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
  54        match self {
  55            Self::CoAuthored(_) => formatter.write_str("Co-authored by an organization member"),
  56            Self::PullRequestReviewed(_) => {
  57                formatter.write_str("Approved by an organization review")
  58            }
  59            Self::ApprovingComment(_) => {
  60                formatter.write_str("Approved by an organization approval comment")
  61            }
  62            Self::ZedZippyCommit(_) => {
  63                formatter.write_str("Fully untampered automated version bump commit")
  64            }
  65        }
  66    }
  67}
  68
  69#[derive(Debug)]
  70pub enum ReviewFailure {
  71    // todo: We could still query the GitHub API here to search for one
  72    NoPullRequestFound,
  73    Unreviewed,
  74    UnexpectedZippyAction(VersionBumpFailure),
  75    Other(anyhow::Error),
  76}
  77
  78impl fmt::Display for ReviewFailure {
  79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
  80        match self {
  81            Self::NoPullRequestFound => formatter.write_str("No pull request found"),
  82            Self::Unreviewed => formatter
  83                .write_str("No qualifying organization approval found for the pull request"),
  84            Self::UnexpectedZippyAction(failure) => {
  85                write!(formatter, "Validating Zed Zippy change failed: {failure}")
  86            }
  87            Self::Other(error) => write!(formatter, "Failed to inspect review state: {error}"),
  88        }
  89    }
  90}
  91
  92#[derive(Debug)]
  93pub enum VersionBumpFailure {
  94    NoMentionInTitle,
  95    MissingCommitData,
  96    AuthorMismatch,
  97    UnexpectedCoAuthors,
  98    NotSigned,
  99    InvalidSignature,
 100    UnexpectedLineChanges { additions: u64, deletions: u64 },
 101}
 102
 103impl fmt::Display for VersionBumpFailure {
 104    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
 105        match self {
 106            Self::NoMentionInTitle => formatter.write_str("No @-mention found in commit title"),
 107            Self::MissingCommitData => formatter.write_str("No commit data found on GitHub"),
 108            Self::AuthorMismatch => {
 109                formatter.write_str("GitHub author does not match bot identity")
 110            }
 111            Self::UnexpectedCoAuthors => formatter.write_str("Commit has unexpected co-authors"),
 112            Self::NotSigned => formatter.write_str("Commit is not signed"),
 113            Self::InvalidSignature => formatter.write_str("Commit signature is invalid"),
 114            Self::UnexpectedLineChanges {
 115                additions,
 116                deletions,
 117            } => {
 118                write!(
 119                    formatter,
 120                    "Unexpected line changes ({additions} additions, {deletions} deletions, \
 121                     expected {EXPECTED_VERSION_BUMP_LOC} each)"
 122                )
 123            }
 124        }
 125    }
 126}
 127
 128pub(crate) type ReviewResult = Result<ReviewSuccess, ReviewFailure>;
 129
 130impl<E: Into<anyhow::Error>> From<E> for ReviewFailure {
 131    fn from(err: E) -> Self {
 132        Self::Other(anyhow::anyhow!(err))
 133    }
 134}
 135
 136pub struct Reporter {
 137    commits: CommitList,
 138    github_client: Rc<dyn GithubApiClient>,
 139}
 140
 141impl Reporter {
 142    pub fn new(commits: CommitList, github_client: Rc<dyn GithubApiClient>) -> Self {
 143        Self {
 144            commits,
 145            github_client,
 146        }
 147    }
 148
 149    pub async fn result_for_commit(
 150        commit: CommitDetails,
 151        github_client: Rc<dyn GithubApiClient>,
 152    ) -> ReviewResult {
 153        Self::new(Default::default(), github_client)
 154            .check_commit(&commit)
 155            .await
 156    }
 157
 158    /// Method that checks every commit for compliance
 159    pub async fn check_commit(
 160        &self,
 161        commit: &CommitDetails,
 162    ) -> Result<ReviewSuccess, ReviewFailure> {
 163        let Some(pr_number) = commit.pr_number() else {
 164            if commit.author().is_zed_zippy() {
 165                return self.check_zippy_version_bump(commit).await;
 166            } else {
 167                return Err(ReviewFailure::NoPullRequestFound);
 168            }
 169        };
 170
 171        let pull_request = self
 172            .github_client
 173            .get_pull_request(&Repository::ZED, pr_number)
 174            .await?;
 175
 176        if let Some(approval) = self
 177            .check_approving_pull_request_review(&pull_request)
 178            .await?
 179        {
 180            return Ok(approval);
 181        }
 182
 183        if let Some(approval) = self
 184            .check_approving_pull_request_comment(&pull_request)
 185            .await?
 186        {
 187            return Ok(approval);
 188        }
 189
 190        if let Some(approval) = self.check_commit_co_authors(commit).await? {
 191            return Ok(approval);
 192        }
 193
 194        Err(ReviewFailure::Unreviewed)
 195    }
 196
 197    async fn check_zippy_version_bump(
 198        &self,
 199        commit: &CommitDetails,
 200    ) -> Result<ReviewSuccess, ReviewFailure> {
 201        let responsible_actor =
 202            commit
 203                .version_bump_mention()
 204                .ok_or(ReviewFailure::UnexpectedZippyAction(
 205                    VersionBumpFailure::NoMentionInTitle,
 206                ))?;
 207
 208        let commit_data = self
 209            .github_client
 210            .get_commit_metadata(&Repository::ZED, &[commit.sha()])
 211            .await?;
 212
 213        let authors = commit_data
 214            .get(commit.sha())
 215            .ok_or(ReviewFailure::UnexpectedZippyAction(
 216                VersionBumpFailure::MissingCommitData,
 217            ))?;
 218
 219        if !authors
 220            .primary_author()
 221            .user()
 222            .is_some_and(|login| login.as_str() == ZED_ZIPPY_LOGIN)
 223        {
 224            return Err(ReviewFailure::UnexpectedZippyAction(
 225                VersionBumpFailure::AuthorMismatch,
 226            ));
 227        }
 228
 229        if authors.co_authors().is_some() {
 230            return Err(ReviewFailure::UnexpectedZippyAction(
 231                VersionBumpFailure::UnexpectedCoAuthors,
 232            ));
 233        }
 234
 235        let signature = authors
 236            .signature()
 237            .ok_or(ReviewFailure::UnexpectedZippyAction(
 238                VersionBumpFailure::NotSigned,
 239            ))?;
 240
 241        if !signature.is_valid() {
 242            return Err(ReviewFailure::UnexpectedZippyAction(
 243                VersionBumpFailure::InvalidSignature,
 244            ));
 245        }
 246
 247        if authors.additions() != EXPECTED_VERSION_BUMP_LOC
 248            || authors.deletions() != EXPECTED_VERSION_BUMP_LOC
 249        {
 250            return Err(ReviewFailure::UnexpectedZippyAction(
 251                VersionBumpFailure::UnexpectedLineChanges {
 252                    additions: authors.additions(),
 253                    deletions: authors.deletions(),
 254                },
 255            ));
 256        }
 257
 258        Ok(ReviewSuccess::ZedZippyCommit(GithubLogin::new(
 259            responsible_actor.to_owned(),
 260        )))
 261    }
 262
 263    async fn check_commit_co_authors(
 264        &self,
 265        commit: &CommitDetails,
 266    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
 267        if commit.co_authors().is_some()
 268            && let Some(commit_authors) = self
 269                .github_client
 270                .get_commit_metadata(&Repository::ZED, &[commit.sha()])
 271                .await?
 272                .get(commit.sha())
 273                .and_then(|authors| authors.co_authors())
 274        {
 275            let mut org_co_authors = Vec::new();
 276            for co_author in commit_authors {
 277                if let Some(github_login) = co_author.user()
 278                    && self
 279                        .github_client
 280                        .check_repo_write_permission(&Repository::ZED, github_login)
 281                        .await?
 282                {
 283                    org_co_authors.push(co_author.clone());
 284                }
 285            }
 286
 287            Ok(org_co_authors
 288                .is_empty()
 289                .not()
 290                .then_some(ReviewSuccess::CoAuthored(org_co_authors)))
 291        } else {
 292            Ok(None)
 293        }
 294    }
 295
 296    async fn check_approving_pull_request_review(
 297        &self,
 298        pull_request: &PullRequestData,
 299    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
 300        let pr_reviews = self
 301            .github_client
 302            .get_pull_request_reviews(&Repository::ZED, pull_request.number)
 303            .await?;
 304
 305        if !pr_reviews.is_empty() {
 306            let mut org_approving_reviews = Vec::new();
 307            for review in pr_reviews {
 308                if let Some(github_login) = review.user.as_ref()
 309                    && pull_request
 310                        .user
 311                        .as_ref()
 312                        .is_none_or(|pr_user| pr_user.login != github_login.login)
 313                    && (review
 314                        .state
 315                        .is_some_and(|state| state == ReviewState::Approved)
 316                        || review
 317                            .body
 318                            .as_deref()
 319                            .is_some_and(Self::contains_approving_pattern))
 320                    && self
 321                        .github_client
 322                        .check_repo_write_permission(
 323                            &Repository::ZED,
 324                            &GithubLogin::new(github_login.login.clone()),
 325                        )
 326                        .await?
 327                {
 328                    org_approving_reviews.push(review);
 329                }
 330            }
 331
 332            Ok(org_approving_reviews
 333                .is_empty()
 334                .not()
 335                .then_some(ReviewSuccess::PullRequestReviewed(org_approving_reviews)))
 336        } else {
 337            Ok(None)
 338        }
 339    }
 340
 341    async fn check_approving_pull_request_comment(
 342        &self,
 343        pull_request: &PullRequestData,
 344    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
 345        let other_comments = self
 346            .github_client
 347            .get_pull_request_comments(&Repository::ZED, pull_request.number)
 348            .await?;
 349
 350        if !other_comments.is_empty() {
 351            let mut org_approving_comments = Vec::new();
 352
 353            for comment in other_comments {
 354                if pull_request
 355                    .user
 356                    .as_ref()
 357                    .is_some_and(|pr_author| pr_author.login != comment.user.login)
 358                    && comment
 359                        .body
 360                        .as_deref()
 361                        .is_some_and(Self::contains_approving_pattern)
 362                    && self
 363                        .github_client
 364                        .check_repo_write_permission(
 365                            &Repository::ZED,
 366                            &GithubLogin::new(comment.user.login.clone()),
 367                        )
 368                        .await?
 369                {
 370                    org_approving_comments.push(comment);
 371                }
 372            }
 373
 374            Ok(org_approving_comments
 375                .is_empty()
 376                .not()
 377                .then_some(ReviewSuccess::ApprovingComment(org_approving_comments)))
 378        } else {
 379            Ok(None)
 380        }
 381    }
 382
 383    fn contains_approving_pattern(body: &str) -> bool {
 384        body.contains(ZED_ZIPPY_COMMENT_APPROVAL_PATTERN) || body.contains(ZED_ZIPPY_GROUP_APPROVAL)
 385    }
 386
 387    pub async fn generate_report(mut self) -> anyhow::Result<Report> {
 388        let mut report = Report::new();
 389
 390        let commits_to_check = std::mem::take(&mut self.commits);
 391        let total_commits = commits_to_check.len();
 392
 393        for (i, commit) in commits_to_check.into_iter().enumerate() {
 394            println!(
 395                "Checking commit {:?} ({current}/{total})",
 396                commit.sha().short(),
 397                current = i + 1,
 398                total = total_commits
 399            );
 400
 401            let review_result = self.check_commit(&commit).await;
 402
 403            if let Err(err) = &review_result {
 404                println!("Commit {:?} failed review: {:?}", commit.sha().short(), err);
 405            }
 406
 407            report.add(commit, review_result);
 408        }
 409
 410        Ok(report)
 411    }
 412}
 413
 414#[cfg(test)]
 415mod tests {
 416    use std::rc::Rc;
 417    use std::str::FromStr;
 418
 419    use crate::git::{CommitDetails, CommitList, CommitSha, ZED_ZIPPY_EMAIL, ZED_ZIPPY_LOGIN};
 420    use crate::github::{
 421        CommitMetadataBySha, GithubApiClient, GithubLogin, GithubUser, PullRequestComment,
 422        PullRequestData, PullRequestReview, Repository, ReviewState,
 423    };
 424
 425    use super::{Reporter, ReviewFailure, ReviewSuccess, VersionBumpFailure};
 426
 427    struct MockGithubApi {
 428        pull_request: PullRequestData,
 429        reviews: Vec<PullRequestReview>,
 430        comments: Vec<PullRequestComment>,
 431        commit_metadata_json: serde_json::Value,
 432        org_members: Vec<String>,
 433    }
 434
 435    #[async_trait::async_trait(?Send)]
 436    impl GithubApiClient for MockGithubApi {
 437        async fn get_pull_request(
 438            &self,
 439            _repo: &Repository<'_>,
 440            _pr_number: u64,
 441        ) -> anyhow::Result<PullRequestData> {
 442            Ok(self.pull_request.clone())
 443        }
 444
 445        async fn get_pull_request_reviews(
 446            &self,
 447            _repo: &Repository<'_>,
 448            _pr_number: u64,
 449        ) -> anyhow::Result<Vec<PullRequestReview>> {
 450            Ok(self.reviews.clone())
 451        }
 452
 453        async fn get_pull_request_comments(
 454            &self,
 455            _repo: &Repository<'_>,
 456            _pr_number: u64,
 457        ) -> anyhow::Result<Vec<PullRequestComment>> {
 458            Ok(self.comments.clone())
 459        }
 460
 461        async fn get_commit_metadata(
 462            &self,
 463            _repo: &Repository<'_>,
 464            _commit_shas: &[&CommitSha],
 465        ) -> anyhow::Result<CommitMetadataBySha> {
 466            serde_json::from_value(self.commit_metadata_json.clone()).map_err(Into::into)
 467        }
 468
 469        async fn check_repo_write_permission(
 470            &self,
 471            _repo: &Repository<'_>,
 472            login: &GithubLogin,
 473        ) -> anyhow::Result<bool> {
 474            Ok(self
 475                .org_members
 476                .iter()
 477                .any(|member| member == login.as_str()))
 478        }
 479
 480        async fn add_label_to_issue(
 481            &self,
 482            _repo: &Repository<'_>,
 483            _label: &str,
 484            _pr_number: u64,
 485        ) -> anyhow::Result<()> {
 486            Ok(())
 487        }
 488    }
 489
 490    fn make_commit(
 491        sha: &str,
 492        author_name: &str,
 493        author_email: &str,
 494        title: &str,
 495        body: &str,
 496    ) -> CommitDetails {
 497        let formatted = format!(
 498            "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|\
 499             {title}|body-delimiter|{body}|commit-delimiter|"
 500        );
 501        CommitList::from_str(&formatted)
 502            .expect("test commit should parse")
 503            .into_iter()
 504            .next()
 505            .expect("should have one commit")
 506    }
 507
 508    fn review(login: &str, state: ReviewState) -> PullRequestReview {
 509        PullRequestReview {
 510            user: Some(GithubUser {
 511                login: login.to_owned(),
 512            }),
 513            state: Some(state),
 514            body: None,
 515        }
 516    }
 517
 518    fn comment(login: &str, body: &str) -> PullRequestComment {
 519        PullRequestComment {
 520            user: GithubUser {
 521                login: login.to_owned(),
 522            },
 523            body: Some(body.to_owned()),
 524        }
 525    }
 526
 527    fn alice_author() -> serde_json::Value {
 528        serde_json::json!({
 529            "name": "Alice",
 530            "email": "alice@test.com",
 531            "user": { "login": "alice" }
 532        })
 533    }
 534
 535    fn bob_author() -> serde_json::Value {
 536        serde_json::json!({
 537            "name": "Bob",
 538            "email": "bob@test.com",
 539            "user": { "login": "bob" }
 540        })
 541    }
 542
 543    fn charlie_author() -> serde_json::Value {
 544        serde_json::json!({
 545            "name": "Charlie",
 546            "email": "charlie@test.com",
 547            "user": { "login": "charlie" }
 548        })
 549    }
 550
 551    fn zippy_author() -> serde_json::Value {
 552        serde_json::json!({
 553            "name": "Zed Zippy",
 554            "email": ZED_ZIPPY_EMAIL,
 555            "user": { "login": ZED_ZIPPY_LOGIN }
 556        })
 557    }
 558
 559    struct TestScenario {
 560        pull_request: PullRequestData,
 561        reviews: Vec<PullRequestReview>,
 562        comments: Vec<PullRequestComment>,
 563        commit_metadata_json: serde_json::Value,
 564        org_members: Vec<String>,
 565        commit: CommitDetails,
 566    }
 567
 568    impl TestScenario {
 569        fn single_commit() -> Self {
 570            Self {
 571                pull_request: PullRequestData {
 572                    number: 1234,
 573                    user: Some(GithubUser {
 574                        login: "alice".to_owned(),
 575                    }),
 576                    merged_by: None,
 577                    labels: None,
 578                },
 579                reviews: vec![],
 580                comments: vec![],
 581                commit_metadata_json: serde_json::json!({}),
 582                org_members: vec![],
 583                commit: make_commit(
 584                    "abc12345abc12345",
 585                    "Alice",
 586                    "alice@test.com",
 587                    "Fix thing (#1234)",
 588                    "",
 589                ),
 590            }
 591        }
 592
 593        fn with_reviews(mut self, reviews: Vec<PullRequestReview>) -> Self {
 594            self.reviews = reviews;
 595            self
 596        }
 597
 598        fn with_comments(mut self, comments: Vec<PullRequestComment>) -> Self {
 599            self.comments = comments;
 600            self
 601        }
 602
 603        fn with_org_members(mut self, members: Vec<&str>) -> Self {
 604            self.org_members = members.into_iter().map(str::to_owned).collect();
 605            self
 606        }
 607
 608        fn with_commit_metadata_json(mut self, json: serde_json::Value) -> Self {
 609            self.commit_metadata_json = json;
 610            self
 611        }
 612
 613        fn with_commit(mut self, commit: CommitDetails) -> Self {
 614            self.commit = commit;
 615            self
 616        }
 617
 618        fn zippy_version_bump() -> Self {
 619            Self {
 620                pull_request: PullRequestData {
 621                    number: 0,
 622                    user: None,
 623                    merged_by: None,
 624                    labels: None,
 625                },
 626                reviews: vec![],
 627                comments: vec![],
 628                commit_metadata_json: serde_json::json!({
 629                    "abc12345abc12345": {
 630                        "author": zippy_author(),
 631                        "authors": { "nodes": [] },
 632                        "signature": {
 633                            "isValid": true,
 634                            "signer": { "login": ZED_ZIPPY_LOGIN }
 635                        },
 636                        "additions": 2,
 637                        "deletions": 2
 638                    }
 639                }),
 640                org_members: vec![],
 641                commit: make_commit(
 642                    "abc12345abc12345",
 643                    "Zed Zippy",
 644                    ZED_ZIPPY_EMAIL,
 645                    "Bump to 0.230.2 for @cole-miller",
 646                    "",
 647                ),
 648            }
 649        }
 650
 651        async fn run_scenario(self) -> Result<ReviewSuccess, ReviewFailure> {
 652            let mock = MockGithubApi {
 653                pull_request: self.pull_request,
 654                reviews: self.reviews,
 655                comments: self.comments,
 656                commit_metadata_json: self.commit_metadata_json,
 657                org_members: self.org_members,
 658            };
 659            let client = Rc::new(mock);
 660            let reporter = Reporter::new(CommitList::default(), client);
 661            reporter.check_commit(&self.commit).await
 662        }
 663    }
 664
 665    #[tokio::test]
 666    async fn approved_review_by_org_member_succeeds() {
 667        let result = TestScenario::single_commit()
 668            .with_reviews(vec![review("bob", ReviewState::Approved)])
 669            .with_org_members(vec!["bob"])
 670            .run_scenario()
 671            .await;
 672        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 673    }
 674
 675    #[tokio::test]
 676    async fn non_approved_review_state_is_not_accepted() {
 677        let result = TestScenario::single_commit()
 678            .with_reviews(vec![review("bob", ReviewState::Other)])
 679            .with_org_members(vec!["bob"])
 680            .run_scenario()
 681            .await;
 682        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 683    }
 684
 685    #[tokio::test]
 686    async fn review_by_non_org_member_is_not_accepted() {
 687        let result = TestScenario::single_commit()
 688            .with_reviews(vec![review("bob", ReviewState::Approved)])
 689            .run_scenario()
 690            .await;
 691        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 692    }
 693
 694    #[tokio::test]
 695    async fn pr_author_own_approval_review_is_rejected() {
 696        let result = TestScenario::single_commit()
 697            .with_reviews(vec![review("alice", ReviewState::Approved)])
 698            .with_org_members(vec!["alice"])
 699            .run_scenario()
 700            .await;
 701        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 702    }
 703
 704    #[tokio::test]
 705    async fn pr_author_own_approval_comment_is_rejected() {
 706        let result = TestScenario::single_commit()
 707            .with_comments(vec![comment("alice", "@zed-zippy approve")])
 708            .with_org_members(vec!["alice"])
 709            .run_scenario()
 710            .await;
 711        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 712    }
 713
 714    #[tokio::test]
 715    async fn approval_comment_by_org_member_succeeds() {
 716        let result = TestScenario::single_commit()
 717            .with_comments(vec![comment("bob", "@zed-zippy approve")])
 718            .with_org_members(vec!["bob"])
 719            .run_scenario()
 720            .await;
 721        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
 722    }
 723
 724    #[tokio::test]
 725    async fn group_approval_comment_by_org_member_succeeds() {
 726        let result = TestScenario::single_commit()
 727            .with_comments(vec![comment("bob", "@zed-industries/approved")])
 728            .with_org_members(vec!["bob"])
 729            .run_scenario()
 730            .await;
 731        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
 732    }
 733
 734    #[tokio::test]
 735    async fn comment_without_approval_pattern_is_not_accepted() {
 736        let result = TestScenario::single_commit()
 737            .with_comments(vec![comment("bob", "looks good")])
 738            .with_org_members(vec!["bob"])
 739            .run_scenario()
 740            .await;
 741        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 742    }
 743
 744    #[tokio::test]
 745    async fn commit_without_pr_number_is_no_pr_found() {
 746        let result = TestScenario::single_commit()
 747            .with_commit(make_commit(
 748                "abc12345abc12345",
 749                "Alice",
 750                "alice@test.com",
 751                "Fix thing without PR number",
 752                "",
 753            ))
 754            .run_scenario()
 755            .await;
 756        assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
 757    }
 758
 759    #[tokio::test]
 760    async fn pr_review_takes_precedence_over_comment() {
 761        let result = TestScenario::single_commit()
 762            .with_reviews(vec![review("bob", ReviewState::Approved)])
 763            .with_comments(vec![comment("charlie", "@zed-zippy approve")])
 764            .with_org_members(vec!["bob", "charlie"])
 765            .run_scenario()
 766            .await;
 767        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 768    }
 769
 770    #[tokio::test]
 771    async fn comment_takes_precedence_over_co_author() {
 772        let result = TestScenario::single_commit()
 773            .with_comments(vec![comment("bob", "@zed-zippy approve")])
 774            .with_commit_metadata_json(serde_json::json!({
 775                "abc12345abc12345": {
 776                    "author": alice_author(),
 777                    "authors": { "nodes": [charlie_author()] }
 778                }
 779            }))
 780            .with_commit(make_commit(
 781                "abc12345abc12345",
 782                "Alice",
 783                "alice@test.com",
 784                "Fix thing (#1234)",
 785                "Co-authored-by: Charlie <charlie@test.com>",
 786            ))
 787            .with_org_members(vec!["bob", "charlie"])
 788            .run_scenario()
 789            .await;
 790        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
 791    }
 792
 793    #[tokio::test]
 794    async fn co_author_org_member_succeeds() {
 795        let result = TestScenario::single_commit()
 796            .with_commit_metadata_json(serde_json::json!({
 797                "abc12345abc12345": {
 798                    "author": alice_author(),
 799                    "authors": { "nodes": [bob_author()] }
 800                }
 801            }))
 802            .with_commit(make_commit(
 803                "abc12345abc12345",
 804                "Alice",
 805                "alice@test.com",
 806                "Fix thing (#1234)",
 807                "Co-authored-by: Bob <bob@test.com>",
 808            ))
 809            .with_org_members(vec!["bob"])
 810            .run_scenario()
 811            .await;
 812        assert!(matches!(result, Ok(ReviewSuccess::CoAuthored(_))));
 813    }
 814
 815    #[tokio::test]
 816    async fn no_reviews_no_comments_no_coauthors_is_unreviewed() {
 817        let result = TestScenario::single_commit().run_scenario().await;
 818        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 819    }
 820
 821    #[tokio::test]
 822    async fn review_with_zippy_approval_body_is_accepted() {
 823        let result = TestScenario::single_commit()
 824            .with_reviews(vec![
 825                review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
 826            ])
 827            .with_org_members(vec!["bob"])
 828            .run_scenario()
 829            .await;
 830        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 831    }
 832
 833    #[tokio::test]
 834    async fn review_with_group_approval_body_is_accepted() {
 835        let result = TestScenario::single_commit()
 836            .with_reviews(vec![
 837                review("bob", ReviewState::Other).with_body("@zed-industries/approved"),
 838            ])
 839            .with_org_members(vec!["bob"])
 840            .run_scenario()
 841            .await;
 842        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 843    }
 844
 845    #[tokio::test]
 846    async fn review_with_non_approving_body_is_not_accepted() {
 847        let result = TestScenario::single_commit()
 848            .with_reviews(vec![
 849                review("bob", ReviewState::Other).with_body("looks good to me"),
 850            ])
 851            .with_org_members(vec!["bob"])
 852            .run_scenario()
 853            .await;
 854        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 855    }
 856
 857    #[tokio::test]
 858    async fn review_with_approving_body_from_external_user_is_not_accepted() {
 859        let result = TestScenario::single_commit()
 860            .with_reviews(vec![
 861                review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
 862            ])
 863            .run_scenario()
 864            .await;
 865        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 866    }
 867
 868    #[tokio::test]
 869    async fn review_with_approving_body_from_pr_author_is_rejected() {
 870        let result = TestScenario::single_commit()
 871            .with_reviews(vec![
 872                review("alice", ReviewState::Other).with_body("@zed-zippy approve"),
 873            ])
 874            .with_org_members(vec!["alice"])
 875            .run_scenario()
 876            .await;
 877        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 878    }
 879
 880    #[tokio::test]
 881    async fn zippy_version_bump_with_valid_signature_succeeds() {
 882        let result = TestScenario::zippy_version_bump().run_scenario().await;
 883        assert!(matches!(result, Ok(ReviewSuccess::ZedZippyCommit(_))));
 884        if let Ok(ReviewSuccess::ZedZippyCommit(login)) = &result {
 885            assert_eq!(login.as_str(), "cole-miller");
 886        }
 887    }
 888
 889    #[tokio::test]
 890    async fn zippy_version_bump_without_mention_fails() {
 891        let result = TestScenario::zippy_version_bump()
 892            .with_commit(make_commit(
 893                "abc12345abc12345",
 894                "Zed Zippy",
 895                ZED_ZIPPY_EMAIL,
 896                "Bump to 0.230.2",
 897                "",
 898            ))
 899            .run_scenario()
 900            .await;
 901        assert!(matches!(
 902            result,
 903            Err(ReviewFailure::UnexpectedZippyAction(
 904                VersionBumpFailure::NoMentionInTitle
 905            ))
 906        ));
 907    }
 908
 909    #[tokio::test]
 910    async fn zippy_version_bump_without_signature_fails() {
 911        let result = TestScenario::zippy_version_bump()
 912            .with_commit_metadata_json(serde_json::json!({
 913                "abc12345abc12345": {
 914                    "author": zippy_author(),
 915                    "authors": { "nodes": [] },
 916                    "additions": 2,
 917                    "deletions": 2
 918                }
 919            }))
 920            .run_scenario()
 921            .await;
 922        assert!(matches!(
 923            result,
 924            Err(ReviewFailure::UnexpectedZippyAction(
 925                VersionBumpFailure::NotSigned
 926            ))
 927        ));
 928    }
 929
 930    #[tokio::test]
 931    async fn zippy_version_bump_with_invalid_signature_fails() {
 932        let result = TestScenario::zippy_version_bump()
 933            .with_commit_metadata_json(serde_json::json!({
 934                "abc12345abc12345": {
 935                    "author": zippy_author(),
 936                    "authors": { "nodes": [] },
 937                    "signature": {
 938                        "isValid": false,
 939                        "signer": { "login": ZED_ZIPPY_LOGIN }
 940                    },
 941                    "additions": 2,
 942                    "deletions": 2
 943                }
 944            }))
 945            .run_scenario()
 946            .await;
 947        assert!(matches!(
 948            result,
 949            Err(ReviewFailure::UnexpectedZippyAction(
 950                VersionBumpFailure::InvalidSignature
 951            ))
 952        ));
 953    }
 954
 955    #[tokio::test]
 956    async fn zippy_version_bump_with_unequal_line_changes_fails() {
 957        let result = TestScenario::zippy_version_bump()
 958            .with_commit_metadata_json(serde_json::json!({
 959                "abc12345abc12345": {
 960                    "author": zippy_author(),
 961                    "authors": { "nodes": [] },
 962                    "signature": {
 963                        "isValid": true,
 964                        "signer": { "login": ZED_ZIPPY_LOGIN }
 965                    },
 966                    "additions": 5,
 967                    "deletions": 2
 968                }
 969            }))
 970            .run_scenario()
 971            .await;
 972        assert!(matches!(
 973            result,
 974            Err(ReviewFailure::UnexpectedZippyAction(
 975                VersionBumpFailure::UnexpectedLineChanges { .. }
 976            ))
 977        ));
 978    }
 979
 980    #[tokio::test]
 981    async fn zippy_version_bump_with_wrong_github_author_fails() {
 982        let result = TestScenario::zippy_version_bump()
 983            .with_commit_metadata_json(serde_json::json!({
 984                "abc12345abc12345": {
 985                    "author": alice_author(),
 986                    "authors": { "nodes": [] },
 987                    "signature": {
 988                        "isValid": true,
 989                        "signer": { "login": "alice" }
 990                    },
 991                    "additions": 2,
 992                    "deletions": 2
 993                }
 994            }))
 995            .run_scenario()
 996            .await;
 997        assert!(matches!(
 998            result,
 999            Err(ReviewFailure::UnexpectedZippyAction(
1000                VersionBumpFailure::AuthorMismatch
1001            ))
1002        ));
1003    }
1004
1005    #[tokio::test]
1006    async fn zippy_version_bump_with_co_authors_fails() {
1007        let result = TestScenario::zippy_version_bump()
1008            .with_commit_metadata_json(serde_json::json!({
1009                "abc12345abc12345": {
1010                    "author": zippy_author(),
1011                    "authors": { "nodes": [alice_author()] },
1012                    "signature": {
1013                        "isValid": true,
1014                        "signer": { "login": ZED_ZIPPY_LOGIN }
1015                    },
1016                    "additions": 2,
1017                    "deletions": 2
1018                }
1019            }))
1020            .run_scenario()
1021            .await;
1022        assert!(matches!(
1023            result,
1024            Err(ReviewFailure::UnexpectedZippyAction(
1025                VersionBumpFailure::UnexpectedCoAuthors
1026            ))
1027        ));
1028    }
1029
1030    #[tokio::test]
1031    async fn non_zippy_commit_without_pr_is_no_pr_found() {
1032        let result = TestScenario::single_commit()
1033            .with_commit(make_commit(
1034                "abc12345abc12345",
1035                "Alice",
1036                "alice@test.com",
1037                "Some direct push",
1038                "",
1039            ))
1040            .run_scenario()
1041            .await;
1042        assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
1043    }
1044
1045    #[tokio::test]
1046    async fn zippy_commit_with_pr_number_goes_through_normal_flow() {
1047        let result = TestScenario::single_commit()
1048            .with_commit(make_commit(
1049                "abc12345abc12345",
1050                "Zed Zippy",
1051                ZED_ZIPPY_EMAIL,
1052                "Some change (#1234)",
1053                "",
1054            ))
1055            .with_reviews(vec![review("bob", ReviewState::Approved)])
1056            .with_org_members(vec!["bob"])
1057            .run_scenario()
1058            .await;
1059        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
1060    }
1061}