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