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}