xref: /expo/tools/src/code-review/index.ts (revision 43a76f73)
1import chalk from 'chalk';
2
3import Git from '../Git';
4import * as GitHub from '../GitHub';
5import logger from '../Logger';
6import { generateReviewBodyFromOutputs } from './reports';
7import checkMissingChangelogs from './reviewers/checkMissingChangelogs';
8import { ReviewEvent, ReviewComment, ReviewInput, ReviewOutput, ReviewStatus } from './types';
9
10/**
11 * An array with functions whose purpose is to check and review the diff.
12 */
13const REVIEWERS = [checkMissingChangelogs];
14
15/**
16 * Goes through the changes included in given pull request and checks if they meet basic requirements.
17 */
18export async function reviewPullRequestAsync(prNumber: number) {
19  const pr = await GitHub.getPullRequestAsync(prNumber);
20  const user = await GitHub.getAuthenticatedUserAsync();
21
22  logger.info('�� Fetching base commit:', chalk.yellow.bold(pr.base.sha));
23  await Git.fetchAsync({
24    remote: 'origin',
25    ref: pr.base.sha,
26  });
27
28  logger.info('�� Fetching head commit:', chalk.yellow.bold(pr.head.sha));
29  await Git.fetchAsync({
30    remote: 'origin',
31    ref: pr.head.sha,
32  });
33
34  // Find the common ancestor of the base and PR's head.
35  const mergeBaseSha = await Git.mergeBaseAsync(pr.base.sha, pr.head.sha);
36  logger.info('�� Found common ancestor:', chalk.yellow.bold(mergeBaseSha));
37
38  // Gets the diff of the pull request.
39  const diff = await Git.getDiffAsync(mergeBaseSha, pr.head.sha);
40
41  const input: ReviewInput = {
42    pullRequest: pr,
43    mergeBaseSha,
44    diff,
45  };
46
47  // Run all the checks asynchronously and collects their outputs.
48  logger.info('��️‍♀️  Reviewing changes');
49  const outputs = (await Promise.all(REVIEWERS.map((reviewer) => reviewer(input)))).filter(
50    Boolean
51  ) as ReviewOutput[];
52
53  // Only active (non-passive) outputs will be reported in the review body.
54  const activeOutputs = outputs.filter(
55    (output) => output.title && output.body && output.status !== ReviewStatus.PASSIVE
56  );
57
58  // Get a list of my previous reviews. We'll invalidate them once the new one is submitted.
59  const previousReviews = (await GitHub.listPullRequestReviewsAsync(pr.number)).filter(
60    (review) => review.user?.login === user.login
61  );
62
63  // Generate review body and decide whether the review needs to request for changes or not.
64  const event = getReviewEventFromOutputs(outputs);
65  const comments = getReviewCommentsFromOutputs(outputs);
66  const body = generateReviewBodyFromOutputs(activeOutputs, comments.length > 0, pr.head.sha);
67
68  // If it's the first review and there is nothing to complain,
69  // just return early — no need to bother PR's author.
70  if (!activeOutputs.length && !comments.length && !previousReviews.length) {
71    logger.success('�� Everything looks good to me! There is no need to submit a review.');
72    return;
73  }
74
75  // Reset my reviews' current state if I previously requested for changes.
76  if (previousReviews[previousReviews.length - 1]?.state) {
77    logger.info('�� Resetting my review state by re-requesting');
78    await GitHub.requestPullRequestReviewersAsync(pr.number, [user.login]);
79  }
80
81  // Create new pull request review.
82  const review = await GitHub.createPullRequestReviewAsync(pr.number, {
83    body,
84    event,
85    comments,
86  });
87  logger.info('�� Submitted new review at:', chalk.blue(review.html_url));
88
89  // Previous reviews are no longer useful — we would delete them, but
90  // unfortunately they cannot be deleted entirely so we only make them smaller.
91  await invalidatePreviousReviewsAsync(pr.number, previousReviews, review);
92
93  logger.success("�� I'm done!");
94}
95
96/**
97 * Marks previous reviews as outdated by changing its body and linking to the latest one.
98 * Probably no need to keep the old body for history as GitHub shows previous revisions of edited comments.
99 */
100async function invalidatePreviousReviewsAsync(
101  prNumber: number,
102  previousReviews: GitHub.PullRequestReview[],
103  newReview: GitHub.PullRequestReview
104): Promise<void> {
105  for (const review of previousReviews) {
106    await GitHub.updatePullRequestReviewAsync(
107      prNumber,
108      review.id,
109      `*The review previously left here is no longer valid, jump to the latest one �� ${newReview.html_url}*`
110    );
111  }
112  if (previousReviews.length > 0) {
113    logger.info('�� Invalidated previous reviews');
114
115    // In order not to exceed rate limits, it should be enough to remove comments only from the last review.
116    await GitHub.deleteAllPullRequestReviewCommentsAsync(
117      prNumber,
118      previousReviews[previousReviews.length - 1].id
119    );
120  }
121}
122
123/**
124 * If any of the check failed, we want the review to request for changes.
125 * Otherwise, it's just a comment (and so fixes are not obligatory).
126 * There is no case where we approve the PR — we still want a human to review these changes :)
127 */
128function getReviewEventFromOutputs(outputs: ReviewOutput[]): GitHub.PullRequestReviewEvent {
129  return outputs.some((output) => output.status === ReviewStatus.ERROR)
130    ? ReviewEvent.REQUEST_CHANGES
131    : ReviewEvent.COMMENT;
132}
133
134/**
135 * Concats comments from all review outputs.
136 */
137function getReviewCommentsFromOutputs(outputs: ReviewOutput[]): ReviewComment[] {
138  return ([] as ReviewComment[]).concat(...outputs.map((output) => output.comments ?? []));
139}
140