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 { ReviewEvent, ReviewComment, ReviewInput, ReviewOutput, ReviewStatus } from './types'; 9 10/** 11 * An array with functions whose purpose is to check and review the diff. 12 */ 13const REVIEWERS = [checkMissingChangelogs]; 14 15/** 16 * Goes through the changes included in given pull request and checks if they meet basic requirements. 17 */ 18export async function reviewPullRequestAsync(prNumber: number) { 19 const pr = await GitHub.getPullRequestAsync(prNumber); 20 const user = await GitHub.getAuthenticatedUserAsync(); 21 22 logger.info(' Fetching base commit:', chalk.yellow.bold(pr.base.sha)); 23 await Git.fetchAsync({ 24 remote: 'origin', 25 ref: pr.base.sha, 26 }); 27 28 logger.info(' Fetching head commit:', chalk.yellow.bold(pr.head.sha)); 29 await Git.fetchAsync({ 30 remote: 'origin', 31 ref: pr.head.sha, 32 }); 33 34 // Find the common ancestor of the base and PR's head. 35 const mergeBaseSha = await Git.mergeBaseAsync(pr.base.sha, pr.head.sha); 36 logger.info(' Found common ancestor:', chalk.yellow.bold(mergeBaseSha)); 37 38 // Gets the diff of the pull request. 39 const diff = await Git.getDiffAsync(mergeBaseSha, pr.head.sha); 40 41 const input: ReviewInput = { 42 pullRequest: pr, 43 mergeBaseSha, 44 diff, 45 }; 46 47 // Run all the checks asynchronously and collects their outputs. 48 logger.info('️♀️ Reviewing changes'); 49 const outputs = (await Promise.all(REVIEWERS.map((reviewer) => reviewer(input)))).filter( 50 Boolean 51 ) as ReviewOutput[]; 52 53 // Only active (non-passive) outputs will be reported in the review body. 54 const activeOutputs = outputs.filter( 55 (output) => output.title && output.body && output.status !== ReviewStatus.PASSIVE 56 ); 57 58 // Get a list of my previous reviews. We'll invalidate them once the new one is submitted. 59 const previousReviews = (await GitHub.listPullRequestReviewsAsync(pr.number)).filter( 60 (review) => review.user?.login === user.login 61 ); 62 63 // Generate review body and decide whether the review needs to request for changes or not. 64 const event = getReviewEventFromOutputs(outputs); 65 const comments = getReviewCommentsFromOutputs(outputs); 66 const body = generateReviewBodyFromOutputs(activeOutputs, comments.length > 0, pr.head.sha); 67 68 // If it's the first review and there is nothing to complain, 69 // just return early — no need to bother PR's author. 70 if (!activeOutputs.length && !comments.length && !previousReviews.length) { 71 logger.success(' Everything looks good to me! There is no need to submit a review.'); 72 return; 73 } 74 75 // Reset my reviews' current state if I previously requested for changes. 76 if (previousReviews[previousReviews.length - 1]?.state) { 77 logger.info(' Resetting my review state by re-requesting'); 78 await GitHub.requestPullRequestReviewersAsync(pr.number, [user.login]); 79 } 80 81 // Create new pull request review. 82 const review = await GitHub.createPullRequestReviewAsync(pr.number, { 83 body, 84 event, 85 comments, 86 }); 87 logger.info(' Submitted new review at:', chalk.blue(review.html_url)); 88 89 // Previous reviews are no longer useful — we would delete them, but 90 // unfortunately they cannot be deleted entirely so we only make them smaller. 91 await invalidatePreviousReviewsAsync(pr.number, previousReviews, review); 92 93 logger.success(" I'm done!"); 94} 95 96/** 97 * Marks previous reviews as outdated by changing its body and linking to the latest one. 98 * Probably no need to keep the old body for history as GitHub shows previous revisions of edited comments. 99 */ 100async function invalidatePreviousReviewsAsync( 101 prNumber: number, 102 previousReviews: GitHub.PullRequestReview[], 103 newReview: GitHub.PullRequestReview 104): Promise<void> { 105 for (const review of previousReviews) { 106 await GitHub.updatePullRequestReviewAsync( 107 prNumber, 108 review.id, 109 `*The review previously left here is no longer valid, jump to the latest one ${newReview.html_url}*` 110 ); 111 } 112 if (previousReviews.length > 0) { 113 logger.info(' Invalidated previous reviews'); 114 115 // In order not to exceed rate limits, it should be enough to remove comments only from the last review. 116 await GitHub.deleteAllPullRequestReviewCommentsAsync( 117 prNumber, 118 previousReviews[previousReviews.length - 1].id 119 ); 120 } 121} 122 123/** 124 * If any of the check failed, we want the review to request for changes. 125 * Otherwise, it's just a comment (and so fixes are not obligatory). 126 * There is no case where we approve the PR — we still want a human to review these changes :) 127 */ 128function getReviewEventFromOutputs(outputs: ReviewOutput[]): GitHub.PullRequestReviewEvent { 129 return outputs.some((output) => output.status === ReviewStatus.ERROR) 130 ? ReviewEvent.REQUEST_CHANGES 131 : ReviewEvent.COMMENT; 132} 133 134/** 135 * Concats comments from all review outputs. 136 */ 137function getReviewCommentsFromOutputs(outputs: ReviewOutput[]): ReviewComment[] { 138 return ([] as ReviewComment[]).concat(...outputs.map((output) => output.comments ?? [])); 139} 140