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