xref: /expo/tools/src/code-review/index.ts (revision f657683d)
1import chalk from 'chalk';
2
3import Git from '../Git';
4import * as GitHub from '../GitHub';
5import logger from '../Logger';
6import { COMMENT_HEADER, generateReportFromOutputs } from './reports';
7import checkMissingChangelogs from './reviewers/checkMissingChangelogs';
8import lintSwiftFiles from './reviewers/lintSwiftFiles';
9import reviewChangelogEntries from './reviewers/reviewChangelogEntries';
10import reviewForbiddenFiles from './reviewers/reviewForbiddenFiles';
11import {
12  ReviewEvent,
13  ReviewComment,
14  ReviewInput,
15  ReviewOutput,
16  ReviewStatus,
17  Reviewer,
18} from './types';
19
20/**
21 * An array with functions whose purpose is to check and review the diff.
22 */
23const REVIEWERS: Reviewer[] = [
24  {
25    id: 'changelog-checks',
26    action: checkMissingChangelogs,
27  },
28  {
29    id: 'changelog-review',
30    action: reviewChangelogEntries,
31  },
32  {
33    id: 'file-checks',
34    action: reviewForbiddenFiles,
35  },
36  {
37    id: 'swiftlint',
38    action: lintSwiftFiles,
39  },
40];
41
42/**
43 * The maximum number of comments included in the single review.
44 */
45const COMMENTS_LIMIT = 10;
46
47/**
48 * A magic comment template for a reviewer. Magic comments are used to disable specific reviewers.
49 * Available reviewers: {@link REVIEWERS}
50 */
51const getMagicCommentForReviewer = (reviewer: Reviewer) => `<!-- disable:${reviewer.id} -->`;
52
53enum Label {
54  PASSED_CHECKS = 'bot: passed checks',
55  SUGGESTIONS = 'bot: suggestions',
56  NEEDS_CHANGES = 'bot: needs changes',
57}
58
59/**
60 * Goes through the changes included in given pull request and checks if they meet basic requirements.
61 */
62export async function reviewPullRequestAsync(prNumber: number) {
63  const pr = await GitHub.getPullRequestAsync(prNumber);
64  const user = await GitHub.getAuthenticatedUserAsync();
65
66  logger.info('�� Fetching head commit', chalk.yellow.bold(pr.head.sha));
67  await Git.fetchAsync({
68    remote: 'origin',
69    ref: pr.head.sha,
70    depth: pr.commits + 1,
71  });
72
73  // Get the diff of the pull request.
74  const diff = await GitHub.getPullRequestDiffAsync(prNumber);
75
76  const input: ReviewInput = {
77    pullRequest: pr,
78    diff,
79  };
80
81  // Filter out the disabled checks, run the checks asynchronously and collects their outputs.
82  logger.info('��️‍♀️  Reviewing changes');
83  const reviewActions = REVIEWERS.filter(
84    (reviewer) => !pr.body?.includes(getMagicCommentForReviewer(reviewer))
85  ).map(({ action }) => action(input));
86  const outputs = (await Promise.all(reviewActions)).filter(Boolean) as ReviewOutput[];
87
88  // Only active (non-passive) outputs will be reported in the review body.
89  const activeOutputs = outputs.filter(
90    (output) => output.title && output.body && output.status !== ReviewStatus.PASSIVE
91  );
92
93  // Gather comments that will be part of the review.
94  const reviewComments = getReviewCommentsFromOutputs(outputs);
95
96  // Get lists of existing reports and reviews. We'll delete them once the new ones are submitted.
97  const outdatedReports = await findExistingReportsAsync(prNumber, user.id);
98  const outdatedReviews = await findExistingReviewsAsync(prNumber, user.id);
99
100  // Submit a report if there is any non-passive output.
101  if (activeOutputs.length > 0) {
102    const report = generateReportFromOutputs(activeOutputs, pr.head.sha);
103    await submitReportAsync(pr.number, report);
104  }
105
106  // Submit a review if there is any review comment (usually suggestion).
107  if (reviewComments.length > 0) {
108    // As described on GitHub's API docs (https://docs.github.com/en/rest/pulls/reviews#create-a-review-for-a-pull-request),
109    // submitting a review triggers notifications and thus is a subject for rate limiting.
110    // Even though this sends just one request, we've got rate limited once when we sent included many comments.
111    // As an attempt to prevent that, we limit the number of comments.
112    await submitReviewWithCommentsAsync(pr.number, reviewComments.splice(0, COMMENTS_LIMIT));
113  }
114
115  // Log the success if there is nothing to complain.
116  if (!activeOutputs.length && !reviewComments.length) {
117    logger.success(
118      '�� Everything looks good to me! There is no need to submit a report nor a review.'
119    );
120  }
121
122  // Delete outdated reports and reviews and update labels.
123  await deleteOutdatedReportsAsync(outdatedReports);
124  await deleteOutdatedReviewsAsync(pr.number, outdatedReviews);
125  await updateLabelsAsync(pr, getLabelFromOutputs(activeOutputs));
126
127  logger.success("�� I'm done!");
128}
129
130/**
131 * Concats comments from all review outputs.
132 */
133function getReviewCommentsFromOutputs(outputs: ReviewOutput[]): ReviewComment[] {
134  return ([] as ReviewComment[]).concat(...outputs.map((output) => output.comments ?? []));
135}
136
137/**
138 * Returns GitHub's label based on outputs' final status.
139 */
140function getLabelFromOutputs(outputs: ReviewOutput[]): Label {
141  const finalStatus = outputs.reduce(
142    (acc, output) => Math.max(acc, output.status),
143    ReviewStatus.PASSIVE
144  );
145  switch (finalStatus) {
146    case ReviewStatus.ERROR:
147      return Label.NEEDS_CHANGES;
148    case ReviewStatus.WARN:
149      return Label.SUGGESTIONS;
150    default:
151      return Label.PASSED_CHECKS;
152  }
153}
154
155/**
156 * Updates bot's labels of the PR so that only given label is assigned.
157 */
158async function updateLabelsAsync(pr: GitHub.PullRequest, newLabel: Label) {
159  const prLabels = pr.labels.map((label) => label.name);
160  const botLabels = Object.values(Label);
161
162  // Get an array of bot's labels that are already assigned to the PR.
163  const labelsToRemove = botLabels.filter(
164    (label) => label !== newLabel && prLabels.includes(label)
165  );
166
167  for (const labelToRemove of labelsToRemove) {
168    logger.info(`��  Removing ${chalk.yellow(labelToRemove)} label`);
169    await GitHub.removeIssueLabelAsync(pr.number, labelToRemove);
170  }
171  if (!prLabels.includes(newLabel)) {
172    logger.info(`��  Adding ${chalk.yellow(newLabel)} label`);
173    await GitHub.addIssueLabelsAsync(pr.number, [newLabel]);
174  }
175}
176
177/**
178 * Finds all reports made by me and this expotools command in given pull request.
179 */
180async function findExistingReportsAsync(prNumber: number, userId: number) {
181  return (await GitHub.listAllCommentsAsync(prNumber)).filter((comment) => {
182    return comment.user?.id === userId && comment.body?.startsWith(COMMENT_HEADER);
183  });
184}
185
186/**
187 * Finds all reviews submitted by me and this expotools command in given pull request.
188 */
189async function findExistingReviewsAsync(prNumber: number, userId: number) {
190  return (await GitHub.listPullRequestReviewsAsync(prNumber)).filter(
191    (review) => review.user?.id === userId
192  );
193}
194
195/**
196 * Submits a pull request comment with the report.
197 */
198async function submitReportAsync(prNumber: number, reportBody: string) {
199  logger.info(`��  Submitting the report`);
200
201  const comment = await GitHub.createCommentAsync(prNumber, reportBody);
202
203  logger.info('�� Submitted the report at:', chalk.blue(comment.html_url));
204}
205
206/**
207 * Submits a pull request review if there are any review comments.
208 */
209async function submitReviewWithCommentsAsync(prNumber: number, comments: ReviewComment[]) {
210  if (comments.length === 0) {
211    return;
212  }
213
214  logger.info(`��  Submitting the review`);
215
216  // Create new pull request review. The body must remain empty,
217  // otherwise it won't be possible to delete the entire review by deleting its comments.
218  const review = await GitHub.createPullRequestReviewAsync(prNumber, {
219    body: '',
220    event: ReviewEvent.COMMENT,
221    comments,
222  });
223
224  logger.info('�� Submitted the review at:', chalk.blue(review.html_url));
225}
226
227/**
228 * Deletes bot's reports from PR's history.
229 */
230async function deleteOutdatedReportsAsync(reports: GitHub.IssueComment[]) {
231  logger.info('�� Deleting outdated reports');
232  await Promise.all(reports.map((report) => GitHub.deleteCommentAsync(report.id)));
233}
234
235/**
236 * Deletes bot's reviews from PR's history.
237 */
238async function deleteOutdatedReviewsAsync(prNumber: number, reviews: GitHub.PullRequestReview[]) {
239  logger.info('�� Deleting outdated reviews');
240  await Promise.all(
241    reviews.map((review) => GitHub.deleteAllPullRequestReviewCommentsAsync(prNumber, review.id))
242  );
243}
244