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