1import chalk from 'chalk'; 2 3import Git from '../Git'; 4import * as GitHub from '../GitHub'; 5import logger from '../Logger'; 6import { generateReviewBodyFromOutputs } 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 ReviewState, 17} from './types'; 18 19/** 20 * An array with functions whose purpose is to check and review the diff. 21 */ 22const REVIEWERS = [checkMissingChangelogs, reviewChangelogEntries, reviewForbiddenFiles]; 23 24enum Label { 25 PASSED_CHECKS = 'bot: passed checks', 26 SUGGESTIONS = 'bot: suggestions', 27 NEEDS_CHANGES = 'bot: needs changes', 28} 29 30/** 31 * Goes through the changes included in given pull request and checks if they meet basic requirements. 32 */ 33export async function reviewPullRequestAsync(prNumber: number) { 34 const pr = await GitHub.getPullRequestAsync(prNumber); 35 const user = await GitHub.getAuthenticatedUserAsync(); 36 37 // Fetch the base commit with a depth that is equal to the number of commits in the PR increased by one. 38 // The last one is a merge base. 39 logger.info( 40 ' Fetching base commit', 41 chalk.yellow.bold(pr.head.sha), 42 'with depth', 43 chalk.yellow((pr.commits + 1).toString()) 44 ); 45 await Git.fetchAsync({ 46 remote: 'origin', 47 ref: pr.head.sha, 48 depth: pr.commits + 1, 49 }); 50 51 // Get the diff of the pull request. 52 const diff = await Git.getDiffAsync(`${pr.head.sha}~${pr.commits}`, pr.head.sha); 53 54 const input: ReviewInput = { 55 pullRequest: pr, 56 diff, 57 }; 58 59 // Run all the checks asynchronously and collects their outputs. 60 logger.info('️♀️ Reviewing changes'); 61 const outputs = (await Promise.all(REVIEWERS.map((reviewer) => reviewer(input)))).filter( 62 Boolean 63 ) as ReviewOutput[]; 64 65 // Only active (non-passive) outputs will be reported in the review body. 66 const activeOutputs = outputs.filter( 67 (output) => output.title && output.body && output.status !== ReviewStatus.PASSIVE 68 ); 69 70 // Get a list of my previous reviews. We'll invalidate them once the new one is submitted. 71 const previousReviews = (await GitHub.listPullRequestReviewsAsync(pr.number)).filter( 72 (review) => review.user?.login === user.login 73 ); 74 75 // Generate review body and decide whether the review needs to request for changes or not. 76 const event = getReviewEventFromOutputs(outputs); 77 const comments = getReviewCommentsFromOutputs(outputs); 78 const body = generateReviewBodyFromOutputs(activeOutputs, comments.length > 0, pr.head.sha); 79 80 // If it's the first review and there is nothing to complain, 81 // just return early — no need to bother PR's author. 82 if (!activeOutputs.length && !comments.length && !previousReviews.length) { 83 await updateLabelsAsync(pr, Label.PASSED_CHECKS); 84 logger.success(' Everything looks good to me! There is no need to submit a review.'); 85 return; 86 } 87 88 // Reset my reviews' current state if I previously requested for changes. 89 if ( 90 previousReviews[previousReviews.length - 1]?.state === ReviewState.CHANGES_REQUESTED && 91 event !== ReviewEvent.REQUEST_CHANGES 92 ) { 93 logger.info(' Resetting my review state by re-requesting'); 94 await GitHub.requestPullRequestReviewersAsync(pr.number, [user.login]); 95 } 96 97 // Create new pull request review. 98 const review = await GitHub.createPullRequestReviewAsync(pr.number, { 99 body, 100 event, 101 comments, 102 }); 103 logger.info(' Submitted new review at:', chalk.blue(review.html_url)); 104 105 // As we never approve the PR by the bot (don't want to bypass the "at least one approval" requirement), 106 // adding appropriate labels instead seems to be a good compromise and makes it clear which PRs are ready to be reviewed by us. 107 const label = getLabelFromOutputs(activeOutputs); 108 await updateLabelsAsync(pr, label); 109 110 // Previous reviews are no longer useful — we would delete them, but 111 // unfortunately they cannot be deleted entirely so we only make them smaller. 112 await invalidatePreviousReviewsAsync(pr.number, previousReviews, review); 113 114 logger.success(" I'm done!"); 115} 116 117/** 118 * Marks previous reviews as outdated by changing its body and linking to the latest one. 119 * Probably no need to keep the old body for history as GitHub shows previous revisions of edited comments. 120 */ 121async function invalidatePreviousReviewsAsync( 122 prNumber: number, 123 previousReviews: GitHub.PullRequestReview[], 124 newReview: GitHub.PullRequestReview 125): Promise<void> { 126 for (const review of previousReviews) { 127 await GitHub.updatePullRequestReviewAsync( 128 prNumber, 129 review.id, 130 `*The review previously left here is no longer valid, jump to the latest one ${newReview.html_url}*` 131 ); 132 } 133 if (previousReviews.length > 0) { 134 logger.info(' Invalidated previous reviews'); 135 136 // In order not to exceed rate limits, it should be enough to remove comments only from the last review. 137 await GitHub.deleteAllPullRequestReviewCommentsAsync( 138 prNumber, 139 previousReviews[previousReviews.length - 1].id 140 ); 141 } 142} 143 144/** 145 * If any of the check failed, we want the review to request for changes. 146 * Otherwise, it's just a comment (and so fixes are not obligatory). 147 * There is no case where we approve the PR — we still want a human to review these changes :) 148 */ 149function getReviewEventFromOutputs(outputs: ReviewOutput[]): GitHub.PullRequestReviewEvent { 150 return outputs.some((output) => output.status === ReviewStatus.ERROR) 151 ? ReviewEvent.REQUEST_CHANGES 152 : ReviewEvent.COMMENT; 153} 154 155/** 156 * Concats comments from all review outputs. 157 */ 158function getReviewCommentsFromOutputs(outputs: ReviewOutput[]): ReviewComment[] { 159 return ([] as ReviewComment[]).concat(...outputs.map((output) => output.comments ?? [])); 160} 161 162/** 163 * Returns GitHub's label based on outputs' final status. 164 */ 165function getLabelFromOutputs(outputs: ReviewOutput[]): Label { 166 const finalStatus = outputs.reduce( 167 (acc, output) => Math.max(acc, output.status), 168 ReviewStatus.PASSIVE 169 ); 170 switch (finalStatus) { 171 case ReviewStatus.ERROR: 172 return Label.NEEDS_CHANGES; 173 case ReviewStatus.WARN: 174 return Label.SUGGESTIONS; 175 default: 176 return Label.PASSED_CHECKS; 177 } 178} 179 180/** 181 * Updates bot's labels of the PR so that only given label is assigned. 182 */ 183async function updateLabelsAsync(pr: GitHub.PullRequest, newLabel: Label) { 184 const prLabels = pr.labels.map((label) => label.name); 185 const botLabels = Object.values(Label); 186 187 // Get an array of bot's labels that are already assigned to the PR. 188 const labelsToRemove = botLabels.filter( 189 (label) => label !== newLabel && prLabels.includes(label) 190 ); 191 192 for (const labelToRemove of labelsToRemove) { 193 logger.info(` Removing ${chalk.yellow(labelToRemove)} label`); 194 await GitHub.removeIssueLabelAsync(pr.number, labelToRemove); 195 } 196 if (!prLabels.includes(newLabel)) { 197 logger.info(` Adding ${chalk.yellow(newLabel)} label`); 198 await GitHub.addIssueLabelsAsync(pr.number, [newLabel]); 199 } 200} 201