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