1use std::{fmt, ops::Not as _, rc::Rc};
2
3use itertools::Itertools as _;
4
5use crate::{
6 git::{CommitDetails, CommitList},
7 github::{
8 CommitAuthor, GithubApiClient, GithubLogin, PullRequestComment, PullRequestData,
9 PullRequestReview, Repository, ReviewState, ZED_ZIPPY_AUTHOR,
10 },
11 report::Report,
12};
13
14const ZED_ZIPPY_COMMENT_APPROVAL_PATTERN: &str = "@zed-zippy approve";
15const ZED_ZIPPY_GROUP_APPROVAL: &str = "@zed-industries/approved";
16
17#[derive(Debug)]
18pub enum ReviewSuccess {
19 ApprovingComment(Vec<PullRequestComment>),
20 CoAuthored(Vec<CommitAuthor>),
21 PullRequestReviewed(Vec<PullRequestReview>),
22}
23
24impl ReviewSuccess {
25 pub(crate) fn reviewers(&self) -> anyhow::Result<String> {
26 let reviewers = match self {
27 Self::CoAuthored(authors) => authors.iter().map(ToString::to_string).collect_vec(),
28 Self::PullRequestReviewed(reviews) => reviews
29 .iter()
30 .filter_map(|review| review.user.as_ref())
31 .map(|user| format!("@{}", user.login))
32 .collect_vec(),
33 Self::ApprovingComment(comments) => comments
34 .iter()
35 .map(|comment| format!("@{}", comment.user.login))
36 .collect_vec(),
37 };
38
39 let reviewers = reviewers.into_iter().unique().collect_vec();
40
41 reviewers
42 .is_empty()
43 .not()
44 .then(|| reviewers.join(", "))
45 .ok_or_else(|| anyhow::anyhow!("Expected at least one reviewer"))
46 }
47}
48
49impl fmt::Display for ReviewSuccess {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::CoAuthored(_) => formatter.write_str("Co-authored by an organization member"),
53 Self::PullRequestReviewed(_) => {
54 formatter.write_str("Approved by an organization review")
55 }
56 Self::ApprovingComment(_) => {
57 formatter.write_str("Approved by an organization approval comment")
58 }
59 }
60 }
61}
62
63#[derive(Debug)]
64pub enum ReviewFailure {
65 // todo: We could still query the GitHub API here to search for one
66 NoPullRequestFound,
67 Unreviewed,
68 Other(anyhow::Error),
69}
70
71impl fmt::Display for ReviewFailure {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::NoPullRequestFound => formatter.write_str("No pull request found"),
75 Self::Unreviewed => formatter
76 .write_str("No qualifying organization approval found for the pull request"),
77 Self::Other(error) => write!(formatter, "Failed to inspect review state: {error}"),
78 }
79 }
80}
81
82pub(crate) type ReviewResult = Result<ReviewSuccess, ReviewFailure>;
83
84impl<E: Into<anyhow::Error>> From<E> for ReviewFailure {
85 fn from(err: E) -> Self {
86 Self::Other(anyhow::anyhow!(err))
87 }
88}
89
90pub struct Reporter {
91 commits: CommitList,
92 github_client: Rc<dyn GithubApiClient>,
93}
94
95impl Reporter {
96 pub fn new(commits: CommitList, github_client: Rc<dyn GithubApiClient>) -> Self {
97 Self {
98 commits,
99 github_client,
100 }
101 }
102
103 pub async fn result_for_commit(
104 commit: CommitDetails,
105 github_client: Rc<dyn GithubApiClient>,
106 ) -> ReviewResult {
107 Self::new(Default::default(), github_client)
108 .check_commit(&commit)
109 .await
110 }
111
112 /// Method that checks every commit for compliance
113 pub async fn check_commit(
114 &self,
115 commit: &CommitDetails,
116 ) -> Result<ReviewSuccess, ReviewFailure> {
117 let Some(pr_number) = commit.pr_number() else {
118 if commit.author().name().contains("Zed Zippy") && commit.title().starts_with("Bump to")
119 {
120 return Ok(ReviewSuccess::CoAuthored(vec![ZED_ZIPPY_AUTHOR.clone()]));
121 }
122 return Err(ReviewFailure::NoPullRequestFound);
123 };
124
125 let pull_request = self
126 .github_client
127 .get_pull_request(&Repository::ZED, pr_number)
128 .await?;
129
130 if let Some(approval) = self
131 .check_approving_pull_request_review(&pull_request)
132 .await?
133 {
134 return Ok(approval);
135 }
136
137 if let Some(approval) = self
138 .check_approving_pull_request_comment(&pull_request)
139 .await?
140 {
141 return Ok(approval);
142 }
143
144 if let Some(approval) = self.check_commit_co_authors(commit).await? {
145 return Ok(approval);
146 }
147
148 Err(ReviewFailure::Unreviewed)
149 }
150
151 async fn check_commit_co_authors(
152 &self,
153 commit: &CommitDetails,
154 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
155 if commit.co_authors().is_some()
156 && let Some(commit_authors) = self
157 .github_client
158 .get_commit_authors(&Repository::ZED, &[commit.sha()])
159 .await?
160 .get(commit.sha())
161 .and_then(|authors| authors.co_authors())
162 {
163 let mut org_co_authors = Vec::new();
164 for co_author in commit_authors {
165 if let Some(github_login) = co_author.user()
166 && self
167 .github_client
168 .check_repo_write_permission(&Repository::ZED, github_login)
169 .await?
170 {
171 org_co_authors.push(co_author.clone());
172 }
173 }
174
175 Ok(org_co_authors
176 .is_empty()
177 .not()
178 .then_some(ReviewSuccess::CoAuthored(org_co_authors)))
179 } else {
180 Ok(None)
181 }
182 }
183
184 async fn check_approving_pull_request_review(
185 &self,
186 pull_request: &PullRequestData,
187 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
188 let pr_reviews = self
189 .github_client
190 .get_pull_request_reviews(&Repository::ZED, pull_request.number)
191 .await?;
192
193 if !pr_reviews.is_empty() {
194 let mut org_approving_reviews = Vec::new();
195 for review in pr_reviews {
196 if let Some(github_login) = review.user.as_ref()
197 && pull_request
198 .user
199 .as_ref()
200 .is_none_or(|pr_user| pr_user.login != github_login.login)
201 && (review
202 .state
203 .is_some_and(|state| state == ReviewState::Approved)
204 || review
205 .body
206 .as_deref()
207 .is_some_and(Self::contains_approving_pattern))
208 && self
209 .github_client
210 .check_repo_write_permission(
211 &Repository::ZED,
212 &GithubLogin::new(github_login.login.clone()),
213 )
214 .await?
215 {
216 org_approving_reviews.push(review);
217 }
218 }
219
220 Ok(org_approving_reviews
221 .is_empty()
222 .not()
223 .then_some(ReviewSuccess::PullRequestReviewed(org_approving_reviews)))
224 } else {
225 Ok(None)
226 }
227 }
228
229 async fn check_approving_pull_request_comment(
230 &self,
231 pull_request: &PullRequestData,
232 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
233 let other_comments = self
234 .github_client
235 .get_pull_request_comments(&Repository::ZED, pull_request.number)
236 .await?;
237
238 if !other_comments.is_empty() {
239 let mut org_approving_comments = Vec::new();
240
241 for comment in other_comments {
242 if pull_request
243 .user
244 .as_ref()
245 .is_some_and(|pr_author| pr_author.login != comment.user.login)
246 && comment
247 .body
248 .as_deref()
249 .is_some_and(Self::contains_approving_pattern)
250 && self
251 .github_client
252 .check_repo_write_permission(
253 &Repository::ZED,
254 &GithubLogin::new(comment.user.login.clone()),
255 )
256 .await?
257 {
258 org_approving_comments.push(comment);
259 }
260 }
261
262 Ok(org_approving_comments
263 .is_empty()
264 .not()
265 .then_some(ReviewSuccess::ApprovingComment(org_approving_comments)))
266 } else {
267 Ok(None)
268 }
269 }
270
271 fn contains_approving_pattern(body: &str) -> bool {
272 body.contains(ZED_ZIPPY_COMMENT_APPROVAL_PATTERN) || body.contains(ZED_ZIPPY_GROUP_APPROVAL)
273 }
274
275 pub async fn generate_report(mut self) -> anyhow::Result<Report> {
276 let mut report = Report::new();
277
278 let commits_to_check = std::mem::take(&mut self.commits);
279 let total_commits = commits_to_check.len();
280
281 for (i, commit) in commits_to_check.into_iter().enumerate() {
282 println!(
283 "Checking commit {:?} ({current}/{total})",
284 commit.sha().short(),
285 current = i + 1,
286 total = total_commits
287 );
288
289 let review_result = self.check_commit(&commit).await;
290
291 if let Err(err) = &review_result {
292 println!("Commit {:?} failed review: {:?}", commit.sha().short(), err);
293 }
294
295 report.add(commit, review_result);
296 }
297
298 Ok(report)
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use std::rc::Rc;
305 use std::str::FromStr;
306
307 use crate::git::{CommitDetails, CommitList, CommitSha};
308 use crate::github::{
309 AuthorsForCommits, GithubApiClient, GithubLogin, GithubUser, PullRequestComment,
310 PullRequestData, PullRequestReview, Repository, ReviewState,
311 };
312
313 use super::{Reporter, ReviewFailure, ReviewSuccess};
314
315 struct MockGithubApi {
316 pull_request: PullRequestData,
317 reviews: Vec<PullRequestReview>,
318 comments: Vec<PullRequestComment>,
319 commit_authors_json: serde_json::Value,
320 org_members: Vec<String>,
321 }
322
323 #[async_trait::async_trait(?Send)]
324 impl GithubApiClient for MockGithubApi {
325 async fn get_pull_request(
326 &self,
327 _repo: &Repository<'_>,
328 _pr_number: u64,
329 ) -> anyhow::Result<PullRequestData> {
330 Ok(self.pull_request.clone())
331 }
332
333 async fn get_pull_request_reviews(
334 &self,
335 _repo: &Repository<'_>,
336 _pr_number: u64,
337 ) -> anyhow::Result<Vec<PullRequestReview>> {
338 Ok(self.reviews.clone())
339 }
340
341 async fn get_pull_request_comments(
342 &self,
343 _repo: &Repository<'_>,
344 _pr_number: u64,
345 ) -> anyhow::Result<Vec<PullRequestComment>> {
346 Ok(self.comments.clone())
347 }
348
349 async fn get_commit_authors(
350 &self,
351 _repo: &Repository<'_>,
352 _commit_shas: &[&CommitSha],
353 ) -> anyhow::Result<AuthorsForCommits> {
354 serde_json::from_value(self.commit_authors_json.clone()).map_err(Into::into)
355 }
356
357 async fn check_repo_write_permission(
358 &self,
359 _repo: &Repository<'_>,
360 login: &GithubLogin,
361 ) -> anyhow::Result<bool> {
362 Ok(self
363 .org_members
364 .iter()
365 .any(|member| member == login.as_str()))
366 }
367
368 async fn add_label_to_issue(
369 &self,
370 _repo: &Repository<'_>,
371 _label: &str,
372 _pr_number: u64,
373 ) -> anyhow::Result<()> {
374 Ok(())
375 }
376 }
377
378 fn make_commit(
379 sha: &str,
380 author_name: &str,
381 author_email: &str,
382 title: &str,
383 body: &str,
384 ) -> CommitDetails {
385 let formatted = format!(
386 "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|\
387 {title}|body-delimiter|{body}|commit-delimiter|"
388 );
389 CommitList::from_str(&formatted)
390 .expect("test commit should parse")
391 .into_iter()
392 .next()
393 .expect("should have one commit")
394 }
395
396 fn review(login: &str, state: ReviewState) -> PullRequestReview {
397 PullRequestReview {
398 user: Some(GithubUser {
399 login: login.to_owned(),
400 }),
401 state: Some(state),
402 body: None,
403 }
404 }
405
406 fn comment(login: &str, body: &str) -> PullRequestComment {
407 PullRequestComment {
408 user: GithubUser {
409 login: login.to_owned(),
410 },
411 body: Some(body.to_owned()),
412 }
413 }
414
415 struct TestScenario {
416 pull_request: PullRequestData,
417 reviews: Vec<PullRequestReview>,
418 comments: Vec<PullRequestComment>,
419 commit_authors_json: serde_json::Value,
420 org_members: Vec<String>,
421 commit: CommitDetails,
422 }
423
424 impl TestScenario {
425 fn single_commit() -> Self {
426 Self {
427 pull_request: PullRequestData {
428 number: 1234,
429 user: Some(GithubUser {
430 login: "alice".to_owned(),
431 }),
432 merged_by: None,
433 labels: None,
434 },
435 reviews: vec![],
436 comments: vec![],
437 commit_authors_json: serde_json::json!({}),
438 org_members: vec![],
439 commit: make_commit(
440 "abc12345abc12345",
441 "Alice",
442 "alice@test.com",
443 "Fix thing (#1234)",
444 "",
445 ),
446 }
447 }
448
449 fn with_reviews(mut self, reviews: Vec<PullRequestReview>) -> Self {
450 self.reviews = reviews;
451 self
452 }
453
454 fn with_comments(mut self, comments: Vec<PullRequestComment>) -> Self {
455 self.comments = comments;
456 self
457 }
458
459 fn with_org_members(mut self, members: Vec<&str>) -> Self {
460 self.org_members = members.into_iter().map(str::to_owned).collect();
461 self
462 }
463
464 fn with_commit_authors_json(mut self, json: serde_json::Value) -> Self {
465 self.commit_authors_json = json;
466 self
467 }
468
469 fn with_commit(mut self, commit: CommitDetails) -> Self {
470 self.commit = commit;
471 self
472 }
473
474 async fn run_scenario(self) -> Result<ReviewSuccess, ReviewFailure> {
475 let mock = MockGithubApi {
476 pull_request: self.pull_request,
477 reviews: self.reviews,
478 comments: self.comments,
479 commit_authors_json: self.commit_authors_json,
480 org_members: self.org_members,
481 };
482 let client = Rc::new(mock);
483 let reporter = Reporter::new(CommitList::default(), client);
484 reporter.check_commit(&self.commit).await
485 }
486 }
487
488 #[tokio::test]
489 async fn approved_review_by_org_member_succeeds() {
490 let result = TestScenario::single_commit()
491 .with_reviews(vec![review("bob", ReviewState::Approved)])
492 .with_org_members(vec!["bob"])
493 .run_scenario()
494 .await;
495 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
496 }
497
498 #[tokio::test]
499 async fn non_approved_review_state_is_not_accepted() {
500 let result = TestScenario::single_commit()
501 .with_reviews(vec![review("bob", ReviewState::Other)])
502 .with_org_members(vec!["bob"])
503 .run_scenario()
504 .await;
505 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
506 }
507
508 #[tokio::test]
509 async fn review_by_non_org_member_is_not_accepted() {
510 let result = TestScenario::single_commit()
511 .with_reviews(vec![review("bob", ReviewState::Approved)])
512 .run_scenario()
513 .await;
514 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
515 }
516
517 #[tokio::test]
518 async fn pr_author_own_approval_review_is_rejected() {
519 let result = TestScenario::single_commit()
520 .with_reviews(vec![review("alice", ReviewState::Approved)])
521 .with_org_members(vec!["alice"])
522 .run_scenario()
523 .await;
524 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
525 }
526
527 #[tokio::test]
528 async fn pr_author_own_approval_comment_is_rejected() {
529 let result = TestScenario::single_commit()
530 .with_comments(vec![comment("alice", "@zed-zippy approve")])
531 .with_org_members(vec!["alice"])
532 .run_scenario()
533 .await;
534 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
535 }
536
537 #[tokio::test]
538 async fn approval_comment_by_org_member_succeeds() {
539 let result = TestScenario::single_commit()
540 .with_comments(vec![comment("bob", "@zed-zippy approve")])
541 .with_org_members(vec!["bob"])
542 .run_scenario()
543 .await;
544 assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
545 }
546
547 #[tokio::test]
548 async fn group_approval_comment_by_org_member_succeeds() {
549 let result = TestScenario::single_commit()
550 .with_comments(vec![comment("bob", "@zed-industries/approved")])
551 .with_org_members(vec!["bob"])
552 .run_scenario()
553 .await;
554 assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
555 }
556
557 #[tokio::test]
558 async fn comment_without_approval_pattern_is_not_accepted() {
559 let result = TestScenario::single_commit()
560 .with_comments(vec![comment("bob", "looks good")])
561 .with_org_members(vec!["bob"])
562 .run_scenario()
563 .await;
564 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
565 }
566
567 #[tokio::test]
568 async fn commit_without_pr_number_is_no_pr_found() {
569 let result = TestScenario::single_commit()
570 .with_commit(make_commit(
571 "abc12345abc12345",
572 "Alice",
573 "alice@test.com",
574 "Fix thing without PR number",
575 "",
576 ))
577 .run_scenario()
578 .await;
579 assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
580 }
581
582 #[tokio::test]
583 async fn pr_review_takes_precedence_over_comment() {
584 let result = TestScenario::single_commit()
585 .with_reviews(vec![review("bob", ReviewState::Approved)])
586 .with_comments(vec![comment("charlie", "@zed-zippy approve")])
587 .with_org_members(vec!["bob", "charlie"])
588 .run_scenario()
589 .await;
590 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
591 }
592
593 #[tokio::test]
594 async fn comment_takes_precedence_over_co_author() {
595 let result = TestScenario::single_commit()
596 .with_comments(vec![comment("bob", "@zed-zippy approve")])
597 .with_commit_authors_json(serde_json::json!({
598 "abc12345abc12345": {
599 "author": {
600 "name": "Alice",
601 "email": "alice@test.com",
602 "user": { "login": "alice" }
603 },
604 "authors": { "nodes": [{
605 "name": "Charlie",
606 "email": "charlie@test.com",
607 "user": { "login": "charlie" }
608 }] }
609 }
610 }))
611 .with_commit(make_commit(
612 "abc12345abc12345",
613 "Alice",
614 "alice@test.com",
615 "Fix thing (#1234)",
616 "Co-authored-by: Charlie <charlie@test.com>",
617 ))
618 .with_org_members(vec!["bob", "charlie"])
619 .run_scenario()
620 .await;
621 assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
622 }
623
624 #[tokio::test]
625 async fn co_author_org_member_succeeds() {
626 let result = TestScenario::single_commit()
627 .with_commit_authors_json(serde_json::json!({
628 "abc12345abc12345": {
629 "author": {
630 "name": "Alice",
631 "email": "alice@test.com",
632 "user": { "login": "alice" }
633 },
634 "authors": { "nodes": [{
635 "name": "Bob",
636 "email": "bob@test.com",
637 "user": { "login": "bob" }
638 }] }
639 }
640 }))
641 .with_commit(make_commit(
642 "abc12345abc12345",
643 "Alice",
644 "alice@test.com",
645 "Fix thing (#1234)",
646 "Co-authored-by: Bob <bob@test.com>",
647 ))
648 .with_org_members(vec!["bob"])
649 .run_scenario()
650 .await;
651 assert!(matches!(result, Ok(ReviewSuccess::CoAuthored(_))));
652 }
653
654 #[tokio::test]
655 async fn no_reviews_no_comments_no_coauthors_is_unreviewed() {
656 let result = TestScenario::single_commit().run_scenario().await;
657 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
658 }
659
660 #[tokio::test]
661 async fn review_with_zippy_approval_body_is_accepted() {
662 let result = TestScenario::single_commit()
663 .with_reviews(vec![
664 review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
665 ])
666 .with_org_members(vec!["bob"])
667 .run_scenario()
668 .await;
669 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
670 }
671
672 #[tokio::test]
673 async fn review_with_group_approval_body_is_accepted() {
674 let result = TestScenario::single_commit()
675 .with_reviews(vec![
676 review("bob", ReviewState::Other).with_body("@zed-industries/approved"),
677 ])
678 .with_org_members(vec!["bob"])
679 .run_scenario()
680 .await;
681 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
682 }
683
684 #[tokio::test]
685 async fn review_with_non_approving_body_is_not_accepted() {
686 let result = TestScenario::single_commit()
687 .with_reviews(vec![
688 review("bob", ReviewState::Other).with_body("looks good to me"),
689 ])
690 .with_org_members(vec!["bob"])
691 .run_scenario()
692 .await;
693 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
694 }
695
696 #[tokio::test]
697 async fn review_with_approving_body_from_external_user_is_not_accepted() {
698 let result = TestScenario::single_commit()
699 .with_reviews(vec![
700 review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
701 ])
702 .run_scenario()
703 .await;
704 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
705 }
706
707 #[tokio::test]
708 async fn review_with_approving_body_from_pr_author_is_rejected() {
709 let result = TestScenario::single_commit()
710 .with_reviews(vec![
711 review("alice", ReviewState::Other).with_body("@zed-zippy approve"),
712 ])
713 .with_org_members(vec!["alice"])
714 .run_scenario()
715 .await;
716 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
717 }
718}