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 pub async fn check_commit(
114 &self,
115 commit: &CommitDetails,
116 ) -> Result<ReviewSuccess, ReviewFailure> {
117 let Some(pr_number) = commit.pr_number() else {
118 return Err(ReviewFailure::NoPullRequestFound);
119 };
120
121 let pull_request = self.github_client.get_pull_request(pr_number).await?;
122
123 if let Some(approval) = self
124 .check_approving_pull_request_review(&pull_request)
125 .await?
126 {
127 return Ok(approval);
128 }
129
130 if let Some(approval) = self
131 .check_approving_pull_request_comment(&pull_request)
132 .await?
133 {
134 return Ok(approval);
135 }
136
137 if let Some(approval) = self.check_commit_co_authors(commit).await? {
138 return Ok(approval);
139 }
140
141 // if let Some(approval) = self.check_external_merged_pr(pr_number).await? {
142 // return Ok(approval);
143 // }
144
145 Err(ReviewFailure::Unreviewed)
146 }
147
148 async fn check_commit_co_authors(
149 &self,
150 commit: &CommitDetails,
151 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
152 if commit.co_authors().is_some()
153 && let Some(commit_authors) = self
154 .github_client
155 .get_commit_authors(&[commit.sha()])
156 .await?
157 .get(commit.sha())
158 .and_then(|authors| authors.co_authors())
159 {
160 let mut org_co_authors = Vec::new();
161 for co_author in commit_authors {
162 if let Some(github_login) = co_author.user()
163 && self
164 .github_client
165 .actor_has_repository_write_permission(github_login)
166 .await?
167 {
168 org_co_authors.push(co_author.clone());
169 }
170 }
171
172 Ok(org_co_authors
173 .is_empty()
174 .not()
175 .then_some(ReviewSuccess::CoAuthored(org_co_authors)))
176 } else {
177 Ok(None)
178 }
179 }
180
181 #[allow(unused)]
182 async fn check_external_merged_pr(
183 &self,
184 pull_request: PullRequestData,
185 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
186 if let Some(user) = pull_request.user
187 && self
188 .github_client
189 .actor_has_repository_write_permission(&GithubLogin::new(user.login))
190 .await?
191 .not()
192 {
193 pull_request.merged_by.map_or(
194 Err(ReviewFailure::UnableToDetermineReviewer),
195 |merged_by| {
196 Ok(Some(ReviewSuccess::ExternalMergedContribution {
197 merged_by,
198 }))
199 },
200 )
201 } else {
202 Ok(None)
203 }
204 }
205
206 async fn check_approving_pull_request_review(
207 &self,
208 pull_request: &PullRequestData,
209 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
210 let pr_reviews = self
211 .github_client
212 .get_pull_request_reviews(pull_request.number)
213 .await?;
214
215 if !pr_reviews.is_empty() {
216 let mut org_approving_reviews = Vec::new();
217 for review in pr_reviews {
218 if let Some(github_login) = review.user.as_ref()
219 && pull_request
220 .user
221 .as_ref()
222 .is_none_or(|pr_user| pr_user.login != github_login.login)
223 && (review
224 .state
225 .is_some_and(|state| state == ReviewState::Approved)
226 || review
227 .body
228 .as_deref()
229 .is_some_and(Self::contains_approving_pattern))
230 && self
231 .github_client
232 .actor_has_repository_write_permission(&GithubLogin::new(
233 github_login.login.clone(),
234 ))
235 .await?
236 {
237 org_approving_reviews.push(review);
238 }
239 }
240
241 Ok(org_approving_reviews
242 .is_empty()
243 .not()
244 .then_some(ReviewSuccess::PullRequestReviewed(org_approving_reviews)))
245 } else {
246 Ok(None)
247 }
248 }
249
250 async fn check_approving_pull_request_comment(
251 &self,
252 pull_request: &PullRequestData,
253 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
254 let other_comments = self
255 .github_client
256 .get_pull_request_comments(pull_request.number)
257 .await?;
258
259 if !other_comments.is_empty() {
260 let mut org_approving_comments = Vec::new();
261
262 for comment in other_comments {
263 if pull_request
264 .user
265 .as_ref()
266 .is_some_and(|pr_author| pr_author.login != comment.user.login)
267 && comment
268 .body
269 .as_deref()
270 .is_some_and(Self::contains_approving_pattern)
271 && self
272 .github_client
273 .actor_has_repository_write_permission(&GithubLogin::new(
274 comment.user.login.clone(),
275 ))
276 .await?
277 {
278 org_approving_comments.push(comment);
279 }
280 }
281
282 Ok(org_approving_comments
283 .is_empty()
284 .not()
285 .then_some(ReviewSuccess::ApprovingComment(org_approving_comments)))
286 } else {
287 Ok(None)
288 }
289 }
290
291 fn contains_approving_pattern(body: &str) -> bool {
292 body.contains(ZED_ZIPPY_COMMENT_APPROVAL_PATTERN) || body.contains(ZED_ZIPPY_GROUP_APPROVAL)
293 }
294
295 pub async fn generate_report(mut self) -> anyhow::Result<Report> {
296 let mut report = Report::new();
297
298 let commits_to_check = std::mem::take(&mut self.commits);
299 let total_commits = commits_to_check.len();
300
301 for (i, commit) in commits_to_check.into_iter().enumerate() {
302 println!(
303 "Checking commit {:?} ({current}/{total})",
304 commit.sha().short(),
305 current = i + 1,
306 total = total_commits
307 );
308
309 let review_result = self.check_commit(&commit).await;
310
311 if let Err(err) = &review_result {
312 println!("Commit {:?} failed review: {:?}", commit.sha().short(), err);
313 }
314
315 report.add(commit, review_result);
316 }
317
318 Ok(report)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use std::rc::Rc;
325 use std::str::FromStr;
326
327 use crate::git::{CommitDetails, CommitList, CommitSha};
328 use crate::github::{
329 AuthorsForCommits, GitHubApiClient, GitHubClient, GitHubUser, GithubLogin,
330 PullRequestComment, PullRequestData, PullRequestReview, ReviewState,
331 };
332
333 use super::{Reporter, ReviewFailure, ReviewSuccess};
334
335 struct MockGitHubApi {
336 pull_request: PullRequestData,
337 reviews: Vec<PullRequestReview>,
338 comments: Vec<PullRequestComment>,
339 commit_authors_json: serde_json::Value,
340 org_members: Vec<String>,
341 }
342
343 #[async_trait::async_trait(?Send)]
344 impl GitHubApiClient for MockGitHubApi {
345 async fn get_pull_request(&self, _pr_number: u64) -> anyhow::Result<PullRequestData> {
346 Ok(self.pull_request.clone())
347 }
348
349 async fn get_pull_request_reviews(
350 &self,
351 _pr_number: u64,
352 ) -> anyhow::Result<Vec<PullRequestReview>> {
353 Ok(self.reviews.clone())
354 }
355
356 async fn get_pull_request_comments(
357 &self,
358 _pr_number: u64,
359 ) -> anyhow::Result<Vec<PullRequestComment>> {
360 Ok(self.comments.clone())
361 }
362
363 async fn get_commit_authors(
364 &self,
365 _commit_shas: &[&CommitSha],
366 ) -> anyhow::Result<AuthorsForCommits> {
367 serde_json::from_value(self.commit_authors_json.clone()).map_err(Into::into)
368 }
369
370 async fn check_org_membership(&self, login: &GithubLogin) -> anyhow::Result<bool> {
371 Ok(self
372 .org_members
373 .iter()
374 .any(|member| member == login.as_str()))
375 }
376
377 async fn check_repo_write_permission(&self, _login: &GithubLogin) -> anyhow::Result<bool> {
378 Ok(false)
379 }
380
381 async fn add_label_to_issue(&self, _label: &str, _pr_number: u64) -> anyhow::Result<()> {
382 Ok(())
383 }
384 }
385
386 fn make_commit(
387 sha: &str,
388 author_name: &str,
389 author_email: &str,
390 title: &str,
391 body: &str,
392 ) -> CommitDetails {
393 let formatted = format!(
394 "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|\
395 {title}|body-delimiter|{body}|commit-delimiter|"
396 );
397 CommitList::from_str(&formatted)
398 .expect("test commit should parse")
399 .into_iter()
400 .next()
401 .expect("should have one commit")
402 }
403
404 fn review(login: &str, state: ReviewState) -> PullRequestReview {
405 PullRequestReview {
406 user: Some(GitHubUser {
407 login: login.to_owned(),
408 }),
409 state: Some(state),
410 body: None,
411 }
412 }
413
414 fn comment(login: &str, body: &str) -> PullRequestComment {
415 PullRequestComment {
416 user: GitHubUser {
417 login: login.to_owned(),
418 },
419 body: Some(body.to_owned()),
420 }
421 }
422
423 struct TestScenario {
424 pull_request: PullRequestData,
425 reviews: Vec<PullRequestReview>,
426 comments: Vec<PullRequestComment>,
427 commit_authors_json: serde_json::Value,
428 org_members: Vec<String>,
429 commit: CommitDetails,
430 }
431
432 impl TestScenario {
433 fn single_commit() -> Self {
434 Self {
435 pull_request: PullRequestData {
436 number: 1234,
437 user: Some(GitHubUser {
438 login: "alice".to_owned(),
439 }),
440 merged_by: None,
441 labels: 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": { "nodes": [{
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": { "nodes": [{
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}