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