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