xref: /expo/tools/src/code-review/index.ts (revision 4e758f24)
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 reviewChangelogEntries from './reviewers/reviewChangelogEntries';
9import reviewForbiddenFiles from './reviewers/reviewForbiddenFiles';
10import {
11  ReviewEvent,
12  ReviewComment,
13  ReviewInput,
14  ReviewOutput,
15  ReviewStatus,
16  ReviewState,
17} from './types';
18
19/**
20 * An array with functions whose purpose is to check and review the diff.
21 */
22const REVIEWERS = [checkMissingChangelogs, reviewChangelogEntries, reviewForbiddenFiles];
23
24enum Label {
25  PASSED_CHECKS = 'bot: passed checks',
26  SUGGESTIONS = 'bot: suggestions',
27  NEEDS_CHANGES = 'bot: needs changes',
28}
29
30/**
31 * Goes through the changes included in given pull request and checks if they meet basic requirements.
32 */
33export async function reviewPullRequestAsync(prNumber: number) {
34  const pr = await GitHub.getPullRequestAsync(prNumber);
35  const user = await GitHub.getAuthenticatedUserAsync();
36
37  // Fetch the base commit with a depth that is equal to the number of commits in the PR increased by one.
38  // The last one is a merge base.
39  logger.info(
40    '�� Fetching base commit',
41    chalk.yellow.bold(pr.head.sha),
42    'with depth',
43    chalk.yellow((pr.commits + 1).toString())
44  );
45  await Git.fetchAsync({
46    remote: 'origin',
47    ref: pr.head.sha,
48    depth: pr.commits + 1,
49  });
50
51  // Get the diff of the pull request.
52  const diff = await Git.getDiffAsync(`${pr.head.sha}~${pr.commits}`, pr.head.sha);
53
54  const input: ReviewInput = {
55    pullRequest: pr,
56    diff,
57  };
58
59  // Run all the checks asynchronously and collects their outputs.
60  logger.info('��️‍♀️  Reviewing changes');
61  const outputs = (await Promise.all(REVIEWERS.map((reviewer) => reviewer(input)))).filter(
62    Boolean
63  ) as ReviewOutput[];
64
65  // Only active (non-passive) outputs will be reported in the review body.
66  const activeOutputs = outputs.filter(
67    (output) => output.title && output.body && output.status !== ReviewStatus.PASSIVE
68  );
69
70  // Get a list of my previous reviews. We'll invalidate them once the new one is submitted.
71  const previousReviews = (await GitHub.listPullRequestReviewsAsync(pr.number)).filter(
72    (review) => review.user?.login === user.login
73  );
74
75  // Generate review body and decide whether the review needs to request for changes or not.
76  const event = getReviewEventFromOutputs(outputs);
77  const comments = getReviewCommentsFromOutputs(outputs);
78  const body = generateReviewBodyFromOutputs(activeOutputs, comments.length > 0, pr.head.sha);
79
80  // If it's the first review and there is nothing to complain,
81  // just return early — no need to bother PR's author.
82  if (!activeOutputs.length && !comments.length && !previousReviews.length) {
83    await updateLabelsAsync(pr, Label.PASSED_CHECKS);
84    logger.success('�� Everything looks good to me! There is no need to submit a review.');
85    return;
86  }
87
88  // Reset my reviews' current state if I previously requested for changes.
89  if (
90    previousReviews[previousReviews.length - 1]?.state === ReviewState.CHANGES_REQUESTED &&
91    event !== ReviewEvent.REQUEST_CHANGES
92  ) {
93    logger.info('�� Resetting my review state by re-requesting');
94    await GitHub.requestPullRequestReviewersAsync(pr.number, [user.login]);
95  }
96
97  // Create new pull request review.
98  const review = await GitHub.createPullRequestReviewAsync(pr.number, {
99    body,
100    event,
101    comments,
102  });
103  logger.info('�� Submitted new review at:', chalk.blue(review.html_url));
104
105  // As we never approve the PR by the bot (don't want to bypass the "at least one approval" requirement),
106  // adding appropriate labels instead seems to be a good compromise and makes it clear which PRs are ready to be reviewed by us.
107  const label = getLabelFromOutputs(activeOutputs);
108  await updateLabelsAsync(pr, label);
109
110  // Previous reviews are no longer useful — we would delete them, but
111  // unfortunately they cannot be deleted entirely so we only make them smaller.
112  await invalidatePreviousReviewsAsync(pr.number, previousReviews, review);
113
114  logger.success("�� I'm done!");
115}
116
117/**
118 * Marks previous reviews as outdated by changing its body and linking to the latest one.
119 * Probably no need to keep the old body for history as GitHub shows previous revisions of edited comments.
120 */
121async function invalidatePreviousReviewsAsync(
122  prNumber: number,
123  previousReviews: GitHub.PullRequestReview[],
124  newReview: GitHub.PullRequestReview
125): Promise<void> {
126  for (const review of previousReviews) {
127    await GitHub.updatePullRequestReviewAsync(
128      prNumber,
129      review.id,
130      `*The review previously left here is no longer valid, jump to the latest one �� ${newReview.html_url}*`
131    );
132  }
133  if (previousReviews.length > 0) {
134    logger.info('�� Invalidated previous reviews');
135
136    // In order not to exceed rate limits, it should be enough to remove comments only from the last review.
137    await GitHub.deleteAllPullRequestReviewCommentsAsync(
138      prNumber,
139      previousReviews[previousReviews.length - 1].id
140    );
141  }
142}
143
144/**
145 * If any of the check failed, we want the review to request for changes.
146 * Otherwise, it's just a comment (and so fixes are not obligatory).
147 * There is no case where we approve the PR — we still want a human to review these changes :)
148 */
149function getReviewEventFromOutputs(outputs: ReviewOutput[]): GitHub.PullRequestReviewEvent {
150  return outputs.some((output) => output.status === ReviewStatus.ERROR)
151    ? ReviewEvent.REQUEST_CHANGES
152    : ReviewEvent.COMMENT;
153}
154
155/**
156 * Concats comments from all review outputs.
157 */
158function getReviewCommentsFromOutputs(outputs: ReviewOutput[]): ReviewComment[] {
159  return ([] as ReviewComment[]).concat(...outputs.map((output) => output.comments ?? []));
160}
161
162/**
163 * Returns GitHub's label based on outputs' final status.
164 */
165function getLabelFromOutputs(outputs: ReviewOutput[]): Label {
166  const finalStatus = outputs.reduce(
167    (acc, output) => Math.max(acc, output.status),
168    ReviewStatus.PASSIVE
169  );
170  switch (finalStatus) {
171    case ReviewStatus.ERROR:
172      return Label.NEEDS_CHANGES;
173    case ReviewStatus.WARN:
174      return Label.SUGGESTIONS;
175    default:
176      return Label.PASSED_CHECKS;
177  }
178}
179
180/**
181 * Updates bot's labels of the PR so that only given label is assigned.
182 */
183async function updateLabelsAsync(pr: GitHub.PullRequest, newLabel: Label) {
184  const prLabels = pr.labels.map((label) => label.name);
185  const botLabels = Object.values(Label);
186
187  // Get an array of bot's labels that are already assigned to the PR.
188  const labelsToRemove = botLabels.filter(
189    (label) => label !== newLabel && prLabels.includes(label)
190  );
191
192  for (const labelToRemove of labelsToRemove) {
193    logger.info(`��  Removing ${chalk.yellow(labelToRemove)} label`);
194    await GitHub.removeIssueLabelAsync(pr.number, labelToRemove);
195  }
196  if (!prLabels.includes(newLabel)) {
197    logger.info(`��  Adding ${chalk.yellow(newLabel)} label`);
198    await GitHub.addIssueLabelsAsync(pr.number, [newLabel]);
199  }
200}
201