1use std::{fmt, ops::Not as _};
2
3use itertools::Itertools as _;
4
5use crate::{
6 git::{CommitDetails, CommitList},
7 github::{
8 CommitAuthor, GithubClient, GithubLogin, GithubUser, PullRequestComment, PullRequestData,
9 PullRequestReview, Repository, ReviewState,
10 },
11 report::Report,
12};
13
14const ZED_ZIPPY_COMMENT_APPROVAL_PATTERN: &str = "@zed-zippy approve";
15const ZED_ZIPPY_GROUP_APPROVAL: &str = "@zed-industries/approved";
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
122 .github_client
123 .get_pull_request(&Repository::ZED, pr_number)
124 .await?;
125
126 if let Some(approval) = self
127 .check_approving_pull_request_review(&pull_request)
128 .await?
129 {
130 return Ok(approval);
131 }
132
133 if let Some(approval) = self
134 .check_approving_pull_request_comment(&pull_request)
135 .await?
136 {
137 return Ok(approval);
138 }
139
140 if let Some(approval) = self.check_commit_co_authors(commit).await? {
141 return Ok(approval);
142 }
143
144 // if let Some(approval) = self.check_external_merged_pr(pr_number).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 #[allow(unused)]
185 async fn check_external_merged_pr(
186 &self,
187 pull_request: PullRequestData,
188 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
189 if let Some(user) = pull_request.user
190 && self
191 .github_client
192 .check_repo_write_permission(&Repository::ZED, &GithubLogin::new(user.login))
193 .await?
194 .not()
195 {
196 pull_request.merged_by.map_or(
197 Err(ReviewFailure::UnableToDetermineReviewer),
198 |merged_by| {
199 Ok(Some(ReviewSuccess::ExternalMergedContribution {
200 merged_by,
201 }))
202 },
203 )
204 } else {
205 Ok(None)
206 }
207 }
208
209 async fn check_approving_pull_request_review(
210 &self,
211 pull_request: &PullRequestData,
212 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
213 let pr_reviews = self
214 .github_client
215 .get_pull_request_reviews(&Repository::ZED, pull_request.number)
216 .await?;
217
218 if !pr_reviews.is_empty() {
219 let mut org_approving_reviews = Vec::new();
220 for review in pr_reviews {
221 if let Some(github_login) = review.user.as_ref()
222 && pull_request
223 .user
224 .as_ref()
225 .is_none_or(|pr_user| pr_user.login != github_login.login)
226 && (review
227 .state
228 .is_some_and(|state| state == ReviewState::Approved)
229 || review
230 .body
231 .as_deref()
232 .is_some_and(Self::contains_approving_pattern))
233 && self
234 .github_client
235 .check_repo_write_permission(
236 &Repository::ZED,
237 &GithubLogin::new(github_login.login.clone()),
238 )
239 .await?
240 {
241 org_approving_reviews.push(review);
242 }
243 }
244
245 Ok(org_approving_reviews
246 .is_empty()
247 .not()
248 .then_some(ReviewSuccess::PullRequestReviewed(org_approving_reviews)))
249 } else {
250 Ok(None)
251 }
252 }
253
254 async fn check_approving_pull_request_comment(
255 &self,
256 pull_request: &PullRequestData,
257 ) -> Result<Option<ReviewSuccess>, ReviewFailure> {
258 let other_comments = self
259 .github_client
260 .get_pull_request_comments(&Repository::ZED, pull_request.number)
261 .await?;
262
263 if !other_comments.is_empty() {
264 let mut org_approving_comments = Vec::new();
265
266 for comment in other_comments {
267 if pull_request
268 .user
269 .as_ref()
270 .is_some_and(|pr_author| pr_author.login != comment.user.login)
271 && comment
272 .body
273 .as_deref()
274 .is_some_and(Self::contains_approving_pattern)
275 && self
276 .github_client
277 .check_repo_write_permission(
278 &Repository::ZED,
279 &GithubLogin::new(comment.user.login.clone()),
280 )
281 .await?
282 {
283 org_approving_comments.push(comment);
284 }
285 }
286
287 Ok(org_approving_comments
288 .is_empty()
289 .not()
290 .then_some(ReviewSuccess::ApprovingComment(org_approving_comments)))
291 } else {
292 Ok(None)
293 }
294 }
295
296 fn contains_approving_pattern(body: &str) -> bool {
297 body.contains(ZED_ZIPPY_COMMENT_APPROVAL_PATTERN) || body.contains(ZED_ZIPPY_GROUP_APPROVAL)
298 }
299
300 pub async fn generate_report(mut self) -> anyhow::Result<Report> {
301 let mut report = Report::new();
302
303 let commits_to_check = std::mem::take(&mut self.commits);
304 let total_commits = commits_to_check.len();
305
306 for (i, commit) in commits_to_check.into_iter().enumerate() {
307 println!(
308 "Checking commit {:?} ({current}/{total})",
309 commit.sha().short(),
310 current = i + 1,
311 total = total_commits
312 );
313
314 let review_result = self.check_commit(&commit).await;
315
316 if let Err(err) = &review_result {
317 println!("Commit {:?} failed review: {:?}", commit.sha().short(), err);
318 }
319
320 report.add(commit, review_result);
321 }
322
323 Ok(report)
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use std::rc::Rc;
330 use std::str::FromStr;
331
332 use crate::git::{CommitDetails, CommitList, CommitSha};
333 use crate::github::{
334 AuthorsForCommits, GithubApiClient, GithubClient, GithubLogin, GithubUser,
335 PullRequestComment, PullRequestData, PullRequestReview, Repository, ReviewState,
336 };
337
338 use super::{Reporter, ReviewFailure, ReviewSuccess};
339
340 struct MockGithubApi {
341 pull_request: PullRequestData,
342 reviews: Vec<PullRequestReview>,
343 comments: Vec<PullRequestComment>,
344 commit_authors_json: serde_json::Value,
345 org_members: Vec<String>,
346 }
347
348 #[async_trait::async_trait(?Send)]
349 impl GithubApiClient for MockGithubApi {
350 async fn get_pull_request(
351 &self,
352 _repo: &Repository<'_>,
353 _pr_number: u64,
354 ) -> anyhow::Result<PullRequestData> {
355 Ok(self.pull_request.clone())
356 }
357
358 async fn get_pull_request_reviews(
359 &self,
360 _repo: &Repository<'_>,
361 _pr_number: u64,
362 ) -> anyhow::Result<Vec<PullRequestReview>> {
363 Ok(self.reviews.clone())
364 }
365
366 async fn get_pull_request_comments(
367 &self,
368 _repo: &Repository<'_>,
369 _pr_number: u64,
370 ) -> anyhow::Result<Vec<PullRequestComment>> {
371 Ok(self.comments.clone())
372 }
373
374 async fn get_commit_authors(
375 &self,
376 _repo: &Repository<'_>,
377 _commit_shas: &[&CommitSha],
378 ) -> anyhow::Result<AuthorsForCommits> {
379 serde_json::from_value(self.commit_authors_json.clone()).map_err(Into::into)
380 }
381
382 async fn check_repo_write_permission(
383 &self,
384 _repo: &Repository<'_>,
385 login: &GithubLogin,
386 ) -> anyhow::Result<bool> {
387 Ok(self
388 .org_members
389 .iter()
390 .any(|member| member == login.as_str()))
391 }
392
393 async fn add_label_to_issue(
394 &self,
395 _repo: &Repository<'_>,
396 _label: &str,
397 _pr_number: u64,
398 ) -> anyhow::Result<()> {
399 Ok(())
400 }
401 }
402
403 fn make_commit(
404 sha: &str,
405 author_name: &str,
406 author_email: &str,
407 title: &str,
408 body: &str,
409 ) -> CommitDetails {
410 let formatted = format!(
411 "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|\
412 {title}|body-delimiter|{body}|commit-delimiter|"
413 );
414 CommitList::from_str(&formatted)
415 .expect("test commit should parse")
416 .into_iter()
417 .next()
418 .expect("should have one commit")
419 }
420
421 fn review(login: &str, state: ReviewState) -> PullRequestReview {
422 PullRequestReview {
423 user: Some(GithubUser {
424 login: login.to_owned(),
425 }),
426 state: Some(state),
427 body: None,
428 }
429 }
430
431 fn comment(login: &str, body: &str) -> PullRequestComment {
432 PullRequestComment {
433 user: GithubUser {
434 login: login.to_owned(),
435 },
436 body: Some(body.to_owned()),
437 }
438 }
439
440 struct TestScenario {
441 pull_request: PullRequestData,
442 reviews: Vec<PullRequestReview>,
443 comments: Vec<PullRequestComment>,
444 commit_authors_json: serde_json::Value,
445 org_members: Vec<String>,
446 commit: CommitDetails,
447 }
448
449 impl TestScenario {
450 fn single_commit() -> Self {
451 Self {
452 pull_request: PullRequestData {
453 number: 1234,
454 user: Some(GithubUser {
455 login: "alice".to_owned(),
456 }),
457 merged_by: None,
458 labels: None,
459 },
460 reviews: vec![],
461 comments: vec![],
462 commit_authors_json: serde_json::json!({}),
463 org_members: vec![],
464 commit: make_commit(
465 "abc12345abc12345",
466 "Alice",
467 "alice@test.com",
468 "Fix thing (#1234)",
469 "",
470 ),
471 }
472 }
473
474 fn with_reviews(mut self, reviews: Vec<PullRequestReview>) -> Self {
475 self.reviews = reviews;
476 self
477 }
478
479 fn with_comments(mut self, comments: Vec<PullRequestComment>) -> Self {
480 self.comments = comments;
481 self
482 }
483
484 fn with_org_members(mut self, members: Vec<&str>) -> Self {
485 self.org_members = members.into_iter().map(str::to_owned).collect();
486 self
487 }
488
489 fn with_commit_authors_json(mut self, json: serde_json::Value) -> Self {
490 self.commit_authors_json = json;
491 self
492 }
493
494 fn with_commit(mut self, commit: CommitDetails) -> Self {
495 self.commit = commit;
496 self
497 }
498
499 async fn run_scenario(self) -> Result<ReviewSuccess, ReviewFailure> {
500 let mock = MockGithubApi {
501 pull_request: self.pull_request,
502 reviews: self.reviews,
503 comments: self.comments,
504 commit_authors_json: self.commit_authors_json,
505 org_members: self.org_members,
506 };
507 let client = GithubClient::new(Rc::new(mock));
508 let reporter = Reporter::new(CommitList::default(), &client);
509 reporter.check_commit(&self.commit).await
510 }
511 }
512
513 #[tokio::test]
514 async fn approved_review_by_org_member_succeeds() {
515 let result = TestScenario::single_commit()
516 .with_reviews(vec![review("bob", ReviewState::Approved)])
517 .with_org_members(vec!["bob"])
518 .run_scenario()
519 .await;
520 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
521 }
522
523 #[tokio::test]
524 async fn non_approved_review_state_is_not_accepted() {
525 let result = TestScenario::single_commit()
526 .with_reviews(vec![review("bob", ReviewState::Other)])
527 .with_org_members(vec!["bob"])
528 .run_scenario()
529 .await;
530 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
531 }
532
533 #[tokio::test]
534 async fn review_by_non_org_member_is_not_accepted() {
535 let result = TestScenario::single_commit()
536 .with_reviews(vec![review("bob", ReviewState::Approved)])
537 .run_scenario()
538 .await;
539 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
540 }
541
542 #[tokio::test]
543 async fn pr_author_own_approval_review_is_rejected() {
544 let result = TestScenario::single_commit()
545 .with_reviews(vec![review("alice", ReviewState::Approved)])
546 .with_org_members(vec!["alice"])
547 .run_scenario()
548 .await;
549 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
550 }
551
552 #[tokio::test]
553 async fn pr_author_own_approval_comment_is_rejected() {
554 let result = TestScenario::single_commit()
555 .with_comments(vec![comment("alice", "@zed-zippy approve")])
556 .with_org_members(vec!["alice"])
557 .run_scenario()
558 .await;
559 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
560 }
561
562 #[tokio::test]
563 async fn approval_comment_by_org_member_succeeds() {
564 let result = TestScenario::single_commit()
565 .with_comments(vec![comment("bob", "@zed-zippy approve")])
566 .with_org_members(vec!["bob"])
567 .run_scenario()
568 .await;
569 assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
570 }
571
572 #[tokio::test]
573 async fn group_approval_comment_by_org_member_succeeds() {
574 let result = TestScenario::single_commit()
575 .with_comments(vec![comment("bob", "@zed-industries/approved")])
576 .with_org_members(vec!["bob"])
577 .run_scenario()
578 .await;
579 assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
580 }
581
582 #[tokio::test]
583 async fn comment_without_approval_pattern_is_not_accepted() {
584 let result = TestScenario::single_commit()
585 .with_comments(vec![comment("bob", "looks good")])
586 .with_org_members(vec!["bob"])
587 .run_scenario()
588 .await;
589 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
590 }
591
592 #[tokio::test]
593 async fn commit_without_pr_number_is_no_pr_found() {
594 let result = TestScenario::single_commit()
595 .with_commit(make_commit(
596 "abc12345abc12345",
597 "Alice",
598 "alice@test.com",
599 "Fix thing without PR number",
600 "",
601 ))
602 .run_scenario()
603 .await;
604 assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound)));
605 }
606
607 #[tokio::test]
608 async fn pr_review_takes_precedence_over_comment() {
609 let result = TestScenario::single_commit()
610 .with_reviews(vec![review("bob", ReviewState::Approved)])
611 .with_comments(vec![comment("charlie", "@zed-zippy approve")])
612 .with_org_members(vec!["bob", "charlie"])
613 .run_scenario()
614 .await;
615 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
616 }
617
618 #[tokio::test]
619 async fn comment_takes_precedence_over_co_author() {
620 let result = TestScenario::single_commit()
621 .with_comments(vec![comment("bob", "@zed-zippy approve")])
622 .with_commit_authors_json(serde_json::json!({
623 "abc12345abc12345": {
624 "author": {
625 "name": "Alice",
626 "email": "alice@test.com",
627 "user": { "login": "alice" }
628 },
629 "authors": { "nodes": [{
630 "name": "Charlie",
631 "email": "charlie@test.com",
632 "user": { "login": "charlie" }
633 }] }
634 }
635 }))
636 .with_commit(make_commit(
637 "abc12345abc12345",
638 "Alice",
639 "alice@test.com",
640 "Fix thing (#1234)",
641 "Co-authored-by: Charlie <charlie@test.com>",
642 ))
643 .with_org_members(vec!["bob", "charlie"])
644 .run_scenario()
645 .await;
646 assert!(matches!(result, Ok(ReviewSuccess::ApprovingComment(_))));
647 }
648
649 #[tokio::test]
650 async fn co_author_org_member_succeeds() {
651 let result = TestScenario::single_commit()
652 .with_commit_authors_json(serde_json::json!({
653 "abc12345abc12345": {
654 "author": {
655 "name": "Alice",
656 "email": "alice@test.com",
657 "user": { "login": "alice" }
658 },
659 "authors": { "nodes": [{
660 "name": "Bob",
661 "email": "bob@test.com",
662 "user": { "login": "bob" }
663 }] }
664 }
665 }))
666 .with_commit(make_commit(
667 "abc12345abc12345",
668 "Alice",
669 "alice@test.com",
670 "Fix thing (#1234)",
671 "Co-authored-by: Bob <bob@test.com>",
672 ))
673 .with_org_members(vec!["bob"])
674 .run_scenario()
675 .await;
676 assert!(matches!(result, Ok(ReviewSuccess::CoAuthored(_))));
677 }
678
679 #[tokio::test]
680 async fn no_reviews_no_comments_no_coauthors_is_unreviewed() {
681 let result = TestScenario::single_commit().run_scenario().await;
682 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
683 }
684
685 #[tokio::test]
686 async fn review_with_zippy_approval_body_is_accepted() {
687 let result = TestScenario::single_commit()
688 .with_reviews(vec![
689 review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
690 ])
691 .with_org_members(vec!["bob"])
692 .run_scenario()
693 .await;
694 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
695 }
696
697 #[tokio::test]
698 async fn review_with_group_approval_body_is_accepted() {
699 let result = TestScenario::single_commit()
700 .with_reviews(vec![
701 review("bob", ReviewState::Other).with_body("@zed-industries/approved"),
702 ])
703 .with_org_members(vec!["bob"])
704 .run_scenario()
705 .await;
706 assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_))));
707 }
708
709 #[tokio::test]
710 async fn review_with_non_approving_body_is_not_accepted() {
711 let result = TestScenario::single_commit()
712 .with_reviews(vec![
713 review("bob", ReviewState::Other).with_body("looks good to me"),
714 ])
715 .with_org_members(vec!["bob"])
716 .run_scenario()
717 .await;
718 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
719 }
720
721 #[tokio::test]
722 async fn review_with_approving_body_from_external_user_is_not_accepted() {
723 let result = TestScenario::single_commit()
724 .with_reviews(vec![
725 review("bob", ReviewState::Other).with_body("@zed-zippy approve"),
726 ])
727 .run_scenario()
728 .await;
729 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
730 }
731
732 #[tokio::test]
733 async fn review_with_approving_body_from_pr_author_is_rejected() {
734 let result = TestScenario::single_commit()
735 .with_reviews(vec![
736 review("alice", ReviewState::Other).with_body("@zed-zippy approve"),
737 ])
738 .with_org_members(vec!["alice"])
739 .run_scenario()
740 .await;
741 assert!(matches!(result, Err(ReviewFailure::Unreviewed)));
742 }
743}