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