checks.rs

   1use std::{fmt, ops::Not as _, rc::Rc};
   2
   3use futures::StreamExt;
   4use itertools::Itertools as _;
   5
   6use crate::{
   7    git::{AutomatedChangeKind, CommitDetails, CommitList, ZED_ZIPPY_LOGIN},
   8    github::{
   9        Approvable, CommitAuthor, CommitFileChange, CommitMetadata, GithubApiClient, GithubLogin,
  10        PullRequestComment, PullRequestData, PullRequestReview, Repository, ReviewState,
  11    },
  12    report::{Report, ReportEntry},
  13};
  14
  15const ZED_ZIPPY_COMMENT_APPROVAL_PATTERN: &str = "@zed-zippy approve";
  16const ZED_ZIPPY_GROUP_APPROVAL: &str = "@zed-industries/approved";
  17
  18#[derive(Debug)]
  19pub enum ReviewSuccess {
  20    ApprovingComment(Vec<PullRequestComment>),
  21    CoAuthored(Vec<CommitAuthor>),
  22    PullRequestReviewed(Vec<PullRequestReview>),
  23    ZedZippyCommit(AutomatedChangeKind, 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(kind, _) => {
  63                write!(formatter, "Fully untampered automated {kind}")
  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(AutomatedChangeFailure),
  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 AutomatedChangeFailure {
  94    NoMentionInTitle,
  95    MissingCommitData,
  96    AuthorMismatch,
  97    UnexpectedCoAuthors,
  98    NotSigned,
  99    InvalidSignature,
 100    UnexpectedLineChanges {
 101        kind: AutomatedChangeKind,
 102        additions: u64,
 103        deletions: u64,
 104    },
 105    UnexpectedFiles {
 106        kind: AutomatedChangeKind,
 107        found: Vec<String>,
 108    },
 109}
 110
 111impl fmt::Display for AutomatedChangeFailure {
 112    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
 113        match self {
 114            Self::NoMentionInTitle => formatter.write_str("No @-mention found in commit title"),
 115            Self::MissingCommitData => formatter.write_str("No commit data found on GitHub"),
 116            Self::AuthorMismatch => {
 117                formatter.write_str("GitHub author does not match bot identity")
 118            }
 119            Self::UnexpectedCoAuthors => formatter.write_str("Commit has unexpected co-authors"),
 120            Self::NotSigned => formatter.write_str("Commit is not signed"),
 121            Self::InvalidSignature => formatter.write_str("Commit signature is invalid"),
 122            Self::UnexpectedLineChanges {
 123                kind,
 124                additions,
 125                deletions,
 126            } => {
 127                write!(
 128                    formatter,
 129                    "Unexpected line changes for {kind} \
 130                     ({additions} additions, {deletions} deletions, \
 131                     expected {} each)",
 132                    kind.expected_loc()
 133                )
 134            }
 135            Self::UnexpectedFiles { kind, found } => {
 136                let expected = kind.expected_files().join(", ");
 137                let actual = found.join(", ");
 138                write!(
 139                    formatter,
 140                    "Unexpected files changed for {kind} \
 141                     (expected [{expected}], found [{actual}])"
 142                )
 143            }
 144        }
 145    }
 146}
 147
 148impl AutomatedChangeKind {
 149    fn validate_changes(
 150        self,
 151        metadata: &CommitMetadata,
 152        files: &[CommitFileChange],
 153    ) -> Result<(), AutomatedChangeFailure> {
 154        let expected_loc = self.expected_loc();
 155        if metadata.additions() != expected_loc || metadata.deletions() != expected_loc {
 156            return Err(AutomatedChangeFailure::UnexpectedLineChanges {
 157                kind: self,
 158                additions: metadata.additions(),
 159                deletions: metadata.deletions(),
 160            });
 161        }
 162
 163        let files_differ = files.len() != self.expected_files().len()
 164            || files
 165                .iter()
 166                .any(|f| self.expected_files().contains(&f.filename.as_str()).not());
 167
 168        if files_differ {
 169            return Err(AutomatedChangeFailure::UnexpectedFiles {
 170                kind: self,
 171                found: files.into_iter().map(|f| f.filename.clone()).collect(),
 172            });
 173        }
 174
 175        Ok(())
 176    }
 177}
 178
 179pub(crate) type ReviewResult = Result<ReviewSuccess, ReviewFailure>;
 180
 181impl<E: Into<anyhow::Error>> From<E> for ReviewFailure {
 182    fn from(err: E) -> Self {
 183        Self::Other(anyhow::anyhow!(err))
 184    }
 185}
 186
 187pub struct Reporter {
 188    commits: CommitList,
 189    github_client: Rc<dyn GithubApiClient>,
 190}
 191
 192impl Reporter {
 193    pub fn new(commits: CommitList, github_client: Rc<dyn GithubApiClient>) -> Self {
 194        Self {
 195            commits,
 196            github_client,
 197        }
 198    }
 199
 200    pub async fn result_for_commit(
 201        commit: CommitDetails,
 202        github_client: Rc<dyn GithubApiClient>,
 203    ) -> ReviewResult {
 204        Self::new(Default::default(), github_client)
 205            .check_commit(&commit)
 206            .await
 207    }
 208
 209    /// Method that checks every commit for compliance
 210    pub async fn check_commit(
 211        &self,
 212        commit: &CommitDetails,
 213    ) -> Result<ReviewSuccess, ReviewFailure> {
 214        let Some(pr_number) = commit.pr_number() else {
 215            if commit.author().is_zed_zippy() {
 216                return self.check_zippy_automated_change(commit).await;
 217            } else {
 218                return Err(ReviewFailure::NoPullRequestFound);
 219            }
 220        };
 221
 222        let pull_request = self
 223            .github_client
 224            .get_pull_request(&Repository::ZED, pr_number)
 225            .await?;
 226
 227        if let Some(approval) = self
 228            .check_approving_pull_request_review(&pull_request)
 229            .await?
 230        {
 231            return Ok(approval);
 232        }
 233
 234        if let Some(approval) = self
 235            .check_approving_pull_request_comment(&pull_request)
 236            .await?
 237        {
 238            return Ok(approval);
 239        }
 240
 241        if let Some(approval) = self.check_commit_co_authors(commit).await? {
 242            return Ok(approval);
 243        }
 244
 245        Err(ReviewFailure::Unreviewed)
 246    }
 247
 248    async fn check_zippy_automated_change(
 249        &self,
 250        commit: &CommitDetails,
 251    ) -> Result<ReviewSuccess, ReviewFailure> {
 252        let (change_kind, responsible_actor) =
 253            commit
 254                .detect_automated_change()
 255                .ok_or(ReviewFailure::UnexpectedZippyAction(
 256                    AutomatedChangeFailure::NoMentionInTitle,
 257                ))?;
 258
 259        let commit_data = self
 260            .github_client
 261            .get_commit_metadata(&Repository::ZED, &[commit.sha()])
 262            .await?;
 263
 264        let metadata =
 265            commit_data
 266                .get(commit.sha())
 267                .ok_or(ReviewFailure::UnexpectedZippyAction(
 268                    AutomatedChangeFailure::MissingCommitData,
 269                ))?;
 270
 271        if !metadata
 272            .primary_author()
 273            .user()
 274            .is_some_and(|login| login.as_str() == ZED_ZIPPY_LOGIN)
 275        {
 276            return Err(ReviewFailure::UnexpectedZippyAction(
 277                AutomatedChangeFailure::AuthorMismatch,
 278            ));
 279        }
 280
 281        if metadata.co_authors().is_some() {
 282            return Err(ReviewFailure::UnexpectedZippyAction(
 283                AutomatedChangeFailure::UnexpectedCoAuthors,
 284            ));
 285        }
 286
 287        let signature = metadata
 288            .signature()
 289            .ok_or(ReviewFailure::UnexpectedZippyAction(
 290                AutomatedChangeFailure::NotSigned,
 291            ))?;
 292
 293        if !signature.is_valid() {
 294            return Err(ReviewFailure::UnexpectedZippyAction(
 295                AutomatedChangeFailure::InvalidSignature,
 296            ));
 297        }
 298
 299        let files = self
 300            .github_client
 301            .get_commit_files(&Repository::ZED, commit.sha())
 302            .await?;
 303
 304        change_kind
 305            .validate_changes(metadata, &files)
 306            .map_err(ReviewFailure::UnexpectedZippyAction)?;
 307
 308        Ok(ReviewSuccess::ZedZippyCommit(
 309            change_kind,
 310            GithubLogin::new(responsible_actor.to_owned()),
 311        ))
 312    }
 313
 314    async fn check_commit_co_authors(
 315        &self,
 316        commit: &CommitDetails,
 317    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
 318        if commit.co_authors().is_some()
 319            && let Some(commit_authors) = self
 320                .github_client
 321                .get_commit_metadata(&Repository::ZED, &[commit.sha()])
 322                .await?
 323                .get(commit.sha())
 324                .and_then(|authors| authors.co_authors())
 325        {
 326            let mut org_co_authors = Vec::new();
 327            for co_author in commit_authors {
 328                if let Some(github_login) = co_author.user()
 329                    && self
 330                        .github_client
 331                        .check_repo_write_permission(&Repository::ZED, github_login)
 332                        .await?
 333                {
 334                    org_co_authors.push(co_author.clone());
 335                }
 336            }
 337
 338            Ok(org_co_authors
 339                .is_empty()
 340                .not()
 341                .then_some(ReviewSuccess::CoAuthored(org_co_authors)))
 342        } else {
 343            Ok(None)
 344        }
 345    }
 346
 347    async fn check_approving_pull_request_review(
 348        &self,
 349        pull_request: &PullRequestData,
 350    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
 351        let reviews = self
 352            .github_client
 353            .get_pull_request_reviews(&Repository::ZED, pull_request.number)
 354            .await?;
 355
 356        let qualifying_reviews = reviews
 357            .into_iter()
 358            .filter(|review| Self::is_qualifying_approval(review, pull_request))
 359            .collect_vec();
 360
 361        Ok(qualifying_reviews
 362            .is_empty()
 363            .not()
 364            .then_some(ReviewSuccess::PullRequestReviewed(qualifying_reviews)))
 365    }
 366
 367    async fn check_approving_pull_request_comment(
 368        &self,
 369        pull_request: &PullRequestData,
 370    ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
 371        let comments = self
 372            .github_client
 373            .get_pull_request_comments(&Repository::ZED, pull_request.number)
 374            .await?;
 375
 376        let qualifying_comments = comments
 377            .into_iter()
 378            .filter(|comment| Self::is_qualifying_approval(comment, pull_request))
 379            .collect_vec();
 380
 381        Ok(qualifying_comments
 382            .is_empty()
 383            .not()
 384            .then_some(ReviewSuccess::ApprovingComment(qualifying_comments)))
 385    }
 386
 387    pub fn is_qualifying_approval(item: &impl Approvable, pull_request: &PullRequestData) -> bool {
 388        let Some(author_login) = item.author_login() else {
 389            return false;
 390        };
 391
 392        let distinct_actor = pull_request
 393            .user
 394            .as_ref()
 395            .is_none_or(|pr_user| pr_user.login != author_login);
 396
 397        let approving_pattern = item
 398            .review_state()
 399            .is_some_and(|state| state == ReviewState::Approved)
 400            || item.body().is_some_and(Self::contains_approving_pattern);
 401
 402        let actor_is_authorized = item
 403            .author_association()
 404            .is_some_and(|association| association.has_write_access());
 405
 406        distinct_actor && approving_pattern && actor_is_authorized
 407    }
 408
 409    fn contains_approving_pattern(body: &str) -> bool {
 410        body.contains(ZED_ZIPPY_COMMENT_APPROVAL_PATTERN) || body.contains(ZED_ZIPPY_GROUP_APPROVAL)
 411    }
 412
 413    pub async fn generate_report(mut self, max_concurrent_checks: usize) -> Report {
 414        let commits_to_check = std::mem::take(&mut self.commits);
 415        let total_commits = commits_to_check.len();
 416
 417        let reports = futures::stream::iter(commits_to_check.into_iter().enumerate().map(
 418            async |(i, commit)| {
 419                println!(
 420                    "Checking commit {:?} ({current}/{total})",
 421                    commit.sha().short(),
 422                    current = i + 1,
 423                    total = total_commits
 424                );
 425
 426                let review_result = self.check_commit(&commit).await;
 427
 428                if let Err(err) = &review_result {
 429                    println!("Commit {:?} failed review: {:?}", commit.sha().short(), err);
 430                }
 431
 432                (commit, review_result)
 433            },
 434        ))
 435        .buffered(max_concurrent_checks)
 436        .collect::<Vec<_>>()
 437        .await;
 438
 439        Report::from_entries(
 440            reports
 441                .into_iter()
 442                .map(|(commit, result)| ReportEntry::new(commit, result)),
 443        )
 444    }
 445}
 446
 447#[cfg(test)]
 448mod tests {
 449    use std::rc::Rc;
 450    use std::str::FromStr;
 451
 452    use crate::git::{
 453        AutomatedChangeKind, CommitDetails, CommitList, CommitSha, ZED_ZIPPY_EMAIL, ZED_ZIPPY_LOGIN,
 454    };
 455    use crate::github::{
 456        AuthorAssociation, CommitFileChange, CommitMetadataBySha, GithubApiClient, GithubLogin,
 457        GithubUser, PullRequestComment, PullRequestData, PullRequestReview, Repository,
 458        ReviewState,
 459    };
 460
 461    use super::{AutomatedChangeFailure, Reporter, ReviewFailure, ReviewSuccess};
 462
 463    struct MockGithubApi {
 464        pull_request: PullRequestData,
 465        reviews: Vec<PullRequestReview>,
 466        comments: Vec<PullRequestComment>,
 467        commit_metadata_json: serde_json::Value,
 468        commit_files: Vec<CommitFileChange>,
 469        org_members: Vec<String>,
 470    }
 471
 472    #[async_trait::async_trait(?Send)]
 473    impl GithubApiClient for MockGithubApi {
 474        async fn get_pull_request(
 475            &self,
 476            _repo: &Repository<'_>,
 477            _pr_number: u64,
 478        ) -> anyhow::Result<PullRequestData> {
 479            Ok(self.pull_request.clone())
 480        }
 481
 482        async fn get_pull_request_reviews(
 483            &self,
 484            _repo: &Repository<'_>,
 485            _pr_number: u64,
 486        ) -> anyhow::Result<Vec<PullRequestReview>> {
 487            Ok(self.reviews.clone())
 488        }
 489
 490        async fn get_pull_request_comments(
 491            &self,
 492            _repo: &Repository<'_>,
 493            _pr_number: u64,
 494        ) -> anyhow::Result<Vec<PullRequestComment>> {
 495            Ok(self.comments.clone())
 496        }
 497
 498        async fn get_commit_metadata(
 499            &self,
 500            _repo: &Repository<'_>,
 501            _commit_shas: &[&CommitSha],
 502        ) -> anyhow::Result<CommitMetadataBySha> {
 503            serde_json::from_value(self.commit_metadata_json.clone()).map_err(Into::into)
 504        }
 505
 506        async fn get_commit_files(
 507            &self,
 508            _repo: &Repository<'_>,
 509            _sha: &CommitSha,
 510        ) -> anyhow::Result<Vec<CommitFileChange>> {
 511            Ok(self.commit_files.clone())
 512        }
 513
 514        async fn check_repo_write_permission(
 515            &self,
 516            _repo: &Repository<'_>,
 517            login: &GithubLogin,
 518        ) -> anyhow::Result<bool> {
 519            Ok(self
 520                .org_members
 521                .iter()
 522                .any(|member| member == login.as_str()))
 523        }
 524
 525        async fn add_label_to_issue(
 526            &self,
 527            _repo: &Repository<'_>,
 528            _label: &str,
 529            _pr_number: u64,
 530        ) -> anyhow::Result<()> {
 531            Ok(())
 532        }
 533    }
 534
 535    fn make_commit(
 536        sha: &str,
 537        author_name: &str,
 538        author_email: &str,
 539        title: &str,
 540        body: &str,
 541    ) -> CommitDetails {
 542        let formatted = format!(
 543            "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|\
 544             {title}|body-delimiter|{body}|commit-delimiter|"
 545        );
 546        CommitList::from_str(&formatted)
 547            .expect("test commit should parse")
 548            .into_iter()
 549            .next()
 550            .expect("should have one commit")
 551    }
 552
 553    fn review(
 554        login: &str,
 555        state: ReviewState,
 556        author_association: AuthorAssociation,
 557    ) -> PullRequestReview {
 558        PullRequestReview {
 559            user: Some(GithubUser {
 560                login: login.to_owned(),
 561            }),
 562            state: Some(state),
 563            body: None,
 564            author_association: Some(author_association),
 565        }
 566    }
 567
 568    fn comment(
 569        login: &str,
 570        body: &str,
 571        author_association: AuthorAssociation,
 572    ) -> PullRequestComment {
 573        PullRequestComment {
 574            user: GithubUser {
 575                login: login.to_owned(),
 576            },
 577            body: Some(body.to_owned()),
 578            author_association: Some(author_association),
 579        }
 580    }
 581
 582    fn alice_author() -> serde_json::Value {
 583        serde_json::json!({
 584            "name": "Alice",
 585            "email": "alice@test.com",
 586            "user": { "login": "alice" }
 587        })
 588    }
 589
 590    fn bob_author() -> serde_json::Value {
 591        serde_json::json!({
 592            "name": "Bob",
 593            "email": "bob@test.com",
 594            "user": { "login": "bob" }
 595        })
 596    }
 597
 598    fn charlie_author() -> serde_json::Value {
 599        serde_json::json!({
 600            "name": "Charlie",
 601            "email": "charlie@test.com",
 602            "user": { "login": "charlie" }
 603        })
 604    }
 605
 606    fn zippy_author() -> serde_json::Value {
 607        serde_json::json!({
 608            "name": "Zed Zippy",
 609            "email": ZED_ZIPPY_EMAIL,
 610            "user": { "login": ZED_ZIPPY_LOGIN }
 611        })
 612    }
 613
 614    struct TestScenario {
 615        pull_request: PullRequestData,
 616        reviews: Vec<PullRequestReview>,
 617        comments: Vec<PullRequestComment>,
 618        commit_metadata_json: serde_json::Value,
 619        commit_files: Vec<CommitFileChange>,
 620        org_members: Vec<String>,
 621        commit: CommitDetails,
 622    }
 623
 624    impl TestScenario {
 625        fn single_commit() -> Self {
 626            Self {
 627                pull_request: PullRequestData {
 628                    number: 1234,
 629                    user: Some(GithubUser {
 630                        login: "alice".to_owned(),
 631                    }),
 632                    merged_by: None,
 633                    labels: None,
 634                },
 635                reviews: vec![],
 636                comments: vec![],
 637                commit_metadata_json: serde_json::json!({}),
 638                commit_files: vec![],
 639                org_members: vec![],
 640                commit: make_commit(
 641                    "abc12345abc12345",
 642                    "Alice",
 643                    "alice@test.com",
 644                    "Fix thing (#1234)",
 645                    "",
 646                ),
 647            }
 648        }
 649
 650        fn with_reviews(mut self, reviews: Vec<PullRequestReview>) -> Self {
 651            self.reviews = reviews;
 652            self
 653        }
 654
 655        fn with_comments(mut self, comments: Vec<PullRequestComment>) -> Self {
 656            self.comments = comments;
 657            self
 658        }
 659
 660        fn with_org_members(mut self, members: Vec<&str>) -> Self {
 661            self.org_members = members.into_iter().map(str::to_owned).collect();
 662            self
 663        }
 664
 665        fn with_commit_metadata_json(mut self, json: serde_json::Value) -> Self {
 666            self.commit_metadata_json = json;
 667            self
 668        }
 669
 670        fn with_commit(mut self, commit: CommitDetails) -> Self {
 671            self.commit = commit;
 672            self
 673        }
 674
 675        fn with_commit_files(mut self, filenames: Vec<&str>) -> Self {
 676            self.commit_files = filenames
 677                .into_iter()
 678                .map(|f| CommitFileChange {
 679                    filename: f.to_owned(),
 680                })
 681                .collect();
 682            self
 683        }
 684
 685        fn zippy_version_bump() -> Self {
 686            Self {
 687                pull_request: PullRequestData {
 688                    number: 0,
 689                    user: None,
 690                    merged_by: None,
 691                    labels: None,
 692                },
 693                reviews: vec![],
 694                comments: vec![],
 695                commit_metadata_json: serde_json::json!({
 696                    "abc12345abc12345": {
 697                        "author": zippy_author(),
 698                        "authors": { "nodes": [] },
 699                        "signature": {
 700                            "isValid": true,
 701                            "signer": { "login": ZED_ZIPPY_LOGIN }
 702                        },
 703                        "additions": 2,
 704                        "deletions": 2
 705                    }
 706                }),
 707                commit_files: vec![
 708                    CommitFileChange {
 709                        filename: "Cargo.lock".to_owned(),
 710                    },
 711                    CommitFileChange {
 712                        filename: "crates/zed/Cargo.toml".to_owned(),
 713                    },
 714                ],
 715                org_members: vec![],
 716                commit: make_commit(
 717                    "abc12345abc12345",
 718                    "Zed Zippy",
 719                    ZED_ZIPPY_EMAIL,
 720                    "Bump to 0.230.2 for @cole-miller",
 721                    "",
 722                ),
 723            }
 724        }
 725
 726        fn zippy_release_channel_update() -> Self {
 727            Self {
 728                pull_request: PullRequestData {
 729                    number: 0,
 730                    user: None,
 731                    merged_by: None,
 732                    labels: None,
 733                },
 734                reviews: vec![],
 735                comments: vec![],
 736                commit_metadata_json: serde_json::json!({
 737                    "abc12345abc12345": {
 738                        "author": zippy_author(),
 739                        "authors": { "nodes": [] },
 740                        "signature": {
 741                            "isValid": true,
 742                            "signer": { "login": ZED_ZIPPY_LOGIN }
 743                        },
 744                        "additions": 1,
 745                        "deletions": 1
 746                    }
 747                }),
 748                commit_files: vec![CommitFileChange {
 749                    filename: "crates/zed/RELEASE_CHANNEL".to_owned(),
 750                }],
 751                org_members: vec![],
 752                commit: make_commit(
 753                    "abc12345abc12345",
 754                    "Zed Zippy",
 755                    ZED_ZIPPY_EMAIL,
 756                    "v0.233.x stable for @cole-miller",
 757                    "",
 758                ),
 759            }
 760        }
 761
 762        async fn run_scenario(self) -> Result<ReviewSuccess, ReviewFailure> {
 763            let mock = MockGithubApi {
 764                pull_request: self.pull_request,
 765                reviews: self.reviews,
 766                comments: self.comments,
 767                commit_metadata_json: self.commit_metadata_json,
 768                commit_files: self.commit_files,
 769                org_members: self.org_members,
 770            };
 771            let client = Rc::new(mock);
 772            let reporter = Reporter::new(CommitList::default(), client);
 773            reporter.check_commit(&self.commit).await
 774        }
 775    }
 776
 777    #[tokio::test]
 778    async fn approved_review_by_org_member_succeeds() {
 779        let result = TestScenario::single_commit()
 780            .with_reviews(vec![review(
 781                "bob",
 782                ReviewState::Approved,
 783                AuthorAssociation::Member,
 784            )])
 785            .run_scenario()
 786            .await;
 787        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 788    }
 789
 790    #[tokio::test]
 791    async fn non_approved_review_state_is_not_accepted() {
 792        let result = TestScenario::single_commit()
 793            .with_reviews(vec![review(
 794                "bob",
 795                ReviewState::Other,
 796                AuthorAssociation::Member,
 797            )])
 798            .run_scenario()
 799            .await;
 800        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 801    }
 802
 803    #[tokio::test]
 804    async fn review_by_non_org_member_is_not_accepted() {
 805        let result = TestScenario::single_commit()
 806            .with_reviews(vec![review(
 807                "bob",
 808                ReviewState::Approved,
 809                AuthorAssociation::None,
 810            )])
 811            .run_scenario()
 812            .await;
 813        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 814    }
 815
 816    #[tokio::test]
 817    async fn pr_author_own_approval_review_is_rejected() {
 818        let result = TestScenario::single_commit()
 819            .with_reviews(vec![review(
 820                "alice",
 821                ReviewState::Approved,
 822                AuthorAssociation::Member,
 823            )])
 824            .run_scenario()
 825            .await;
 826        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 827    }
 828
 829    #[tokio::test]
 830    async fn pr_author_own_approval_comment_is_rejected() {
 831        let result = TestScenario::single_commit()
 832            .with_comments(vec![comment(
 833                "alice",
 834                "@zed-zippy approve",
 835                AuthorAssociation::Member,
 836            )])
 837            .run_scenario()
 838            .await;
 839        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 840    }
 841
 842    #[tokio::test]
 843    async fn approval_comment_by_org_member_succeeds() {
 844        let result = TestScenario::single_commit()
 845            .with_comments(vec![comment(
 846                "bob",
 847                "@zed-zippy approve",
 848                AuthorAssociation::Member,
 849            )])
 850            .run_scenario()
 851            .await;
 852        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
 853    }
 854
 855    #[tokio::test]
 856    async fn group_approval_comment_by_org_member_succeeds() {
 857        let result = TestScenario::single_commit()
 858            .with_comments(vec![comment(
 859                "bob",
 860                "@zed-industries/approved",
 861                AuthorAssociation::Member,
 862            )])
 863            .run_scenario()
 864            .await;
 865        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
 866    }
 867
 868    #[tokio::test]
 869    async fn comment_without_approval_pattern_is_not_accepted() {
 870        let result = TestScenario::single_commit()
 871            .with_comments(vec![comment(
 872                "bob",
 873                "looks good",
 874                AuthorAssociation::Member,
 875            )])
 876            .run_scenario()
 877            .await;
 878        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 879    }
 880
 881    #[tokio::test]
 882    async fn commit_without_pr_number_is_no_pr_found() {
 883        let result = TestScenario::single_commit()
 884            .with_commit(make_commit(
 885                "abc12345abc12345",
 886                "Alice",
 887                "alice@test.com",
 888                "Fix thing without PR number",
 889                "",
 890            ))
 891            .run_scenario()
 892            .await;
 893        assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
 894    }
 895
 896    #[tokio::test]
 897    async fn pr_review_takes_precedence_over_comment() {
 898        let result = TestScenario::single_commit()
 899            .with_reviews(vec![review(
 900                "bob",
 901                ReviewState::Approved,
 902                AuthorAssociation::Member,
 903            )])
 904            .with_comments(vec![comment(
 905                "charlie",
 906                "@zed-zippy approve",
 907                AuthorAssociation::Member,
 908            )])
 909            .run_scenario()
 910            .await;
 911        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 912    }
 913
 914    #[tokio::test]
 915    async fn comment_takes_precedence_over_co_author() {
 916        let result = TestScenario::single_commit()
 917            .with_comments(vec![comment(
 918                "bob",
 919                "@zed-zippy approve",
 920                AuthorAssociation::Member,
 921            )])
 922            .with_commit_metadata_json(serde_json::json!({
 923                "abc12345abc12345": {
 924                    "author": alice_author(),
 925                    "authors": { "nodes": [charlie_author()] }
 926                }
 927            }))
 928            .with_commit(make_commit(
 929                "abc12345abc12345",
 930                "Alice",
 931                "alice@test.com",
 932                "Fix thing (#1234)",
 933                "Co-authored-by: Charlie <charlie@test.com>",
 934            ))
 935            .run_scenario()
 936            .await;
 937        assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
 938    }
 939
 940    #[tokio::test]
 941    async fn co_author_org_member_succeeds() {
 942        let result = TestScenario::single_commit()
 943            .with_commit_metadata_json(serde_json::json!({
 944                "abc12345abc12345": {
 945                    "author": alice_author(),
 946                    "authors": { "nodes": [bob_author()] }
 947                }
 948            }))
 949            .with_commit(make_commit(
 950                "abc12345abc12345",
 951                "Alice",
 952                "alice@test.com",
 953                "Fix thing (#1234)",
 954                "Co-authored-by: Bob <bob@test.com>",
 955            ))
 956            .with_org_members(vec!["bob"])
 957            .run_scenario()
 958            .await;
 959        assert!(matches!(result, Ok(ReviewSuccess::CoAuthored(_))));
 960    }
 961
 962    #[tokio::test]
 963    async fn no_reviews_no_comments_no_coauthors_is_unreviewed() {
 964        let result = TestScenario::single_commit().run_scenario().await;
 965        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
 966    }
 967
 968    #[tokio::test]
 969    async fn review_with_zippy_approval_body_is_accepted() {
 970        let result = TestScenario::single_commit()
 971            .with_reviews(vec![
 972                review("bob", ReviewState::Other, AuthorAssociation::Member)
 973                    .with_body("@zed-zippy approve"),
 974            ])
 975            .run_scenario()
 976            .await;
 977        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 978    }
 979
 980    #[tokio::test]
 981    async fn review_with_group_approval_body_is_accepted() {
 982        let result = TestScenario::single_commit()
 983            .with_reviews(vec![
 984                review("bob", ReviewState::Other, AuthorAssociation::Member)
 985                    .with_body("@zed-industries/approved"),
 986            ])
 987            .run_scenario()
 988            .await;
 989        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
 990    }
 991
 992    #[tokio::test]
 993    async fn review_with_non_approving_body_is_not_accepted() {
 994        let result = TestScenario::single_commit()
 995            .with_reviews(vec![
 996                review("bob", ReviewState::Other, AuthorAssociation::Member)
 997                    .with_body("looks good to me"),
 998            ])
 999            .run_scenario()
1000            .await;
1001        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
1002    }
1003
1004    #[tokio::test]
1005    async fn review_with_approving_body_from_external_user_is_not_accepted() {
1006        let result = TestScenario::single_commit()
1007            .with_reviews(vec![
1008                review("bob", ReviewState::Other, AuthorAssociation::None)
1009                    .with_body("@zed-zippy approve"),
1010            ])
1011            .run_scenario()
1012            .await;
1013        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
1014    }
1015
1016    #[tokio::test]
1017    async fn review_with_approving_body_from_pr_author_is_rejected() {
1018        let result = TestScenario::single_commit()
1019            .with_reviews(vec![
1020                review("alice", ReviewState::Other, AuthorAssociation::Member)
1021                    .with_body("@zed-zippy approve"),
1022            ])
1023            .run_scenario()
1024            .await;
1025        assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
1026    }
1027
1028    #[tokio::test]
1029    async fn zippy_version_bump_with_valid_signature_succeeds() {
1030        let result = TestScenario::zippy_version_bump().run_scenario().await;
1031        assert!(matches!(
1032            result,
1033            Ok(ReviewSuccess::ZedZippyCommit(
1034                AutomatedChangeKind::VersionBump,
1035                _
1036            ))
1037        ));
1038        if let Ok(ReviewSuccess::ZedZippyCommit(_, login)) = &result {
1039            assert_eq!(login.as_str(), "cole-miller");
1040        }
1041    }
1042
1043    #[tokio::test]
1044    async fn zippy_version_bump_without_mention_fails() {
1045        let result = TestScenario::zippy_version_bump()
1046            .with_commit(make_commit(
1047                "abc12345abc12345",
1048                "Zed Zippy",
1049                ZED_ZIPPY_EMAIL,
1050                "Bump to 0.230.2",
1051                "",
1052            ))
1053            .run_scenario()
1054            .await;
1055        assert!(matches!(
1056            result,
1057            Err(ReviewFailure::UnexpectedZippyAction(
1058                AutomatedChangeFailure::NoMentionInTitle
1059            ))
1060        ));
1061    }
1062
1063    #[tokio::test]
1064    async fn zippy_version_bump_without_signature_fails() {
1065        let result = TestScenario::zippy_version_bump()
1066            .with_commit_metadata_json(serde_json::json!({
1067                "abc12345abc12345": {
1068                    "author": zippy_author(),
1069                    "authors": { "nodes": [] },
1070                    "additions": 2,
1071                    "deletions": 2
1072                }
1073            }))
1074            .run_scenario()
1075            .await;
1076        assert!(matches!(
1077            result,
1078            Err(ReviewFailure::UnexpectedZippyAction(
1079                AutomatedChangeFailure::NotSigned
1080            ))
1081        ));
1082    }
1083
1084    #[tokio::test]
1085    async fn zippy_version_bump_with_invalid_signature_fails() {
1086        let result = TestScenario::zippy_version_bump()
1087            .with_commit_metadata_json(serde_json::json!({
1088                "abc12345abc12345": {
1089                    "author": zippy_author(),
1090                    "authors": { "nodes": [] },
1091                    "signature": {
1092                        "isValid": false,
1093                        "signer": { "login": ZED_ZIPPY_LOGIN }
1094                    },
1095                    "additions": 2,
1096                    "deletions": 2
1097                }
1098            }))
1099            .run_scenario()
1100            .await;
1101        assert!(matches!(
1102            result,
1103            Err(ReviewFailure::UnexpectedZippyAction(
1104                AutomatedChangeFailure::InvalidSignature
1105            ))
1106        ));
1107    }
1108
1109    #[tokio::test]
1110    async fn zippy_version_bump_with_unequal_line_changes_fails() {
1111        let result = TestScenario::zippy_version_bump()
1112            .with_commit_metadata_json(serde_json::json!({
1113                "abc12345abc12345": {
1114                    "author": zippy_author(),
1115                    "authors": { "nodes": [] },
1116                    "signature": {
1117                        "isValid": true,
1118                        "signer": { "login": ZED_ZIPPY_LOGIN }
1119                    },
1120                    "additions": 5,
1121                    "deletions": 2
1122                }
1123            }))
1124            .run_scenario()
1125            .await;
1126        assert!(matches!(
1127            result,
1128            Err(ReviewFailure::UnexpectedZippyAction(
1129                AutomatedChangeFailure::UnexpectedLineChanges { .. }
1130            ))
1131        ));
1132    }
1133
1134    #[tokio::test]
1135    async fn zippy_version_bump_with_wrong_github_author_fails() {
1136        let result = TestScenario::zippy_version_bump()
1137            .with_commit_metadata_json(serde_json::json!({
1138                "abc12345abc12345": {
1139                    "author": alice_author(),
1140                    "authors": { "nodes": [] },
1141                    "signature": {
1142                        "isValid": true,
1143                        "signer": { "login": "alice" }
1144                    },
1145                    "additions": 2,
1146                    "deletions": 2
1147                }
1148            }))
1149            .run_scenario()
1150            .await;
1151        assert!(matches!(
1152            result,
1153            Err(ReviewFailure::UnexpectedZippyAction(
1154                AutomatedChangeFailure::AuthorMismatch
1155            ))
1156        ));
1157    }
1158
1159    #[tokio::test]
1160    async fn zippy_version_bump_with_co_authors_fails() {
1161        let result = TestScenario::zippy_version_bump()
1162            .with_commit_metadata_json(serde_json::json!({
1163                "abc12345abc12345": {
1164                    "author": zippy_author(),
1165                    "authors": { "nodes": [alice_author()] },
1166                    "signature": {
1167                        "isValid": true,
1168                        "signer": { "login": ZED_ZIPPY_LOGIN }
1169                    },
1170                    "additions": 2,
1171                    "deletions": 2
1172                }
1173            }))
1174            .run_scenario()
1175            .await;
1176        assert!(matches!(
1177            result,
1178            Err(ReviewFailure::UnexpectedZippyAction(
1179                AutomatedChangeFailure::UnexpectedCoAuthors
1180            ))
1181        ));
1182    }
1183
1184    #[tokio::test]
1185    async fn zippy_version_bump_with_wrong_files_fails() {
1186        let result = TestScenario::zippy_version_bump()
1187            .with_commit_files(vec!["crates/zed/RELEASE_CHANNEL"])
1188            .run_scenario()
1189            .await;
1190        assert!(matches!(
1191            result,
1192            Err(ReviewFailure::UnexpectedZippyAction(
1193                AutomatedChangeFailure::UnexpectedFiles { .. }
1194            ))
1195        ));
1196    }
1197
1198    #[tokio::test]
1199    async fn zippy_release_channel_update_succeeds() {
1200        let result = TestScenario::zippy_release_channel_update()
1201            .run_scenario()
1202            .await;
1203        assert!(matches!(
1204            result,
1205            Ok(ReviewSuccess::ZedZippyCommit(
1206                AutomatedChangeKind::ReleaseChannelUpdate,
1207                _
1208            ))
1209        ));
1210        if let Ok(ReviewSuccess::ZedZippyCommit(_, login)) = &result {
1211            assert_eq!(login.as_str(), "cole-miller");
1212        }
1213    }
1214
1215    #[tokio::test]
1216    async fn non_zippy_commit_without_pr_is_no_pr_found() {
1217        let result = TestScenario::single_commit()
1218            .with_commit(make_commit(
1219                "abc12345abc12345",
1220                "Alice",
1221                "alice@test.com",
1222                "Some direct push",
1223                "",
1224            ))
1225            .run_scenario()
1226            .await;
1227        assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
1228    }
1229
1230    #[tokio::test]
1231    async fn zippy_commit_with_pr_number_goes_through_normal_flow() {
1232        let result = TestScenario::single_commit()
1233            .with_commit(make_commit(
1234                "abc12345abc12345",
1235                "Zed Zippy",
1236                ZED_ZIPPY_EMAIL,
1237                "Some change (#1234)",
1238                "",
1239            ))
1240            .with_reviews(vec![review(
1241                "bob",
1242                ReviewState::Approved,
1243                AuthorAssociation::Member,
1244            )])
1245            .run_scenario()
1246            .await;
1247        assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
1248    }
1249}