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