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