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}