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