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