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