import {
REVIEW_SOURCES
} from './reviews.js';
import {
CONFLICTING
} from './mergeable_state.js';
import {
shortSha
} from './utils.js';
import {
JobParser,
CI_TYPES,
CI_PROVIDERS,
isFullCI
} from './ci/ci_type_parser.js';
import { PRBuild } from './ci/build-types/pr_build.js';
const { FROM_COMMENT, FROM_REVIEW_COMMENT } = REVIEW_SOURCES;
const SECOND = 1000;
const MINUTE = SECOND * 60;
const WAIT_TIME_MULTI_APPROVAL = 24 * 2;
const WAIT_TIME_SINGLE_APPROVAL = 24 * 7;
const GITHUB_SUCCESS_CONCLUSIONS = ['SUCCESS', 'NEUTRAL', 'SKIPPED'];
const GITHUB_ACTIONS_APP = 'github-actions';
// A commit keeps every check suite ever attached to it, so a branch that is
// force-pushed away from a SHA and back again accumulates one suite per run.
// Runs of the same workflow supersede one another, but runs triggered by
// different events do not, so both make up the key.
function selectLatestCheckSuites(nodes) {
const latest = new Map();
const unkeyed = [];
for (const suite of nodes) {
const { workflowRun } = suite;
if (!workflowRun) {
// A suite without run metadata cannot be compared against another, so
// it is kept as is rather than dropped.
unkeyed.push(suite);
continue;
}
const key = `${workflowRun.workflow.id}:${workflowRun.event}`;
const current = latest.get(key);
if (!current || workflowRun.runNumber > current.workflowRun.runNumber) {
latest.set(key, suite);
}
}
return [...unkeyed, ...latest.values()];
}
const FAST_TRACK_RE = /^Fast-track has been requested by @(.+?)\. Please 👍 to approve\.$/;
const FAST_TRACK_MIN_APPROVALS = 2;
const GIT_CONFIG_GUIDE_URL = 'https://github.com/nodejs/node/blob/99b1ada/doc/guides/contributing/pull-requests.md#step-1-fork';
export const PR_CHECK_REASON_CODES = Object.freeze({
CANCELLED_GITHUB_CI: 'cancelled-github-ci',
CLOSED: 'closed',
CONFLICT: 'conflict',
FAILED_GITHUB_CI: 'failed-github-ci',
FAILED_JENKINS_CI: 'failed-jenkins-ci',
INVALID_CI_TYPE: 'invalid-ci-type',
MERGED: 'merged',
MISSING_APPROVAL: 'missing-approval',
MISSING_FAST_TRACK_COMMENT: 'missing-fast-track-comment',
MISSING_FULL_JENKINS_CI: 'missing-full-jenkins-ci',
MISSING_GITHUB_CI: 'missing-github-ci',
MISSING_JENKINS_CI: 'missing-jenkins-ci',
MISSING_TSC_APPROVAL: 'missing-tsc-approval',
NEW_CONTRIBUTOR: 'new-contributor',
NO_COMMITS: 'no-commits',
PENDING_GITHUB_CI: 'pending-github-ci',
PENDING_JENKINS_CI: 'pending-jenkins-ci',
REQUESTED_CHANGES: 'requested-changes',
STALE_CI: 'stale-ci',
STALE_REVIEW: 'stale-review',
WAIT_TIME: 'wait-time'
});
export default class PRChecker {
/**
* @param {{}} cli
* @param {PRData} data
*/
constructor(cli, data, request, argv) {
this.cli = cli;
this.request = request;
this.data = data;
const {
pr, reviewers, comments, reviews, commits
} = data;
this.reviewers = reviewers;
this.pr = pr;
this.comments = comments;
// this.reviews and this.commits must
// be in order as received from github api
// to check if new commits were pushed after
// the last review.
this.reviews = reviews;
this.commits = commits;
this.argv = argv;
this.reasons = [];
}
addReason(code, message, details = {}) {
this.reasons.push({
code,
message,
...details
});
}
getResult(status) {
return {
ready: status,
reasons: this.reasons
};
}
get waitTimeSingleApproval() {
if (this.argv.waitTimeSingleApproval === undefined) {
return WAIT_TIME_SINGLE_APPROVAL;
}
return this.argv.waitTimeSingleApproval;
}
get waitTimeMultiApproval() {
if (this.argv.waitTimeMultiApproval === undefined) {
return WAIT_TIME_MULTI_APPROVAL;
}
return this.argv.waitTimeMultiApproval;
}
async checkAll(checkComments = false, checkCI = true) {
this.reasons = [];
const status = [
this.checkCommitsAfterReview(),
this.checkReviewsAndWait(new Date(), checkComments),
this.checkMergeableState(),
this.checkPRState(),
this.checkGitConfig()
];
if (checkCI) {
status.push(await this.checkCI());
}
if (this.data.authorIsNew()) {
status.push(this.checkAuthor());
}
// TODO: check for pre-backport, Github API v4
// does not support reading files changed
return status.every((i) => i);
}
getTSC(people) {
return people
.filter((p) => p.reviewer.isTSC())
.map((p) => p.reviewer.login);
}
formatReview(reviewer, review) {
let hint = '';
if (reviewer.isTSC()) {
hint = ' (TSC)';
}
return `- ${reviewer.getName()}${hint}: ${review.ref}`;
}
displayReviews(checkComments) {
const { cli, reviewers: { requestedChanges, approved } } = this;
if (requestedChanges.length > 0) {
const message = `Requested Changes: ${requestedChanges.length}`;
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.REQUESTED_CHANGES, message, {
count: requestedChanges.length
});
for (const { reviewer, review } of requestedChanges) {
cli.error(this.formatReview(reviewer, review));
}
}
if (approved.length === 0) {
const message = 'Approvals: 0';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.MISSING_APPROVAL, message);
return;
}
cli.ok(`Approvals: ${approved.length}`);
for (const { reviewer, review } of approved) {
cli.ok(this.formatReview(reviewer, review));
if (checkComments &&
[FROM_COMMENT, FROM_REVIEW_COMMENT].includes(review.source)) {
cli.info(`- ${reviewer.getName()} approved in via LGTM in comments`);
}
}
}
checkReviewsAndWait(now, checkComments, isReleaseProposal) {
const {
pr, cli, reviewers
} = this;
const { requestedChanges, approved } = reviewers;
const dateStr = new Date(pr.createdAt).toUTCString();
cli.info(`This PR was created on ${dateStr}`);
this.displayReviews(checkComments);
if (isReleaseProposal) {
cli.info('This PR is a release proposal');
return approved.length !== 0;
}
const labels = pr.labels.nodes.map((l) => l.name);
let isFastTracked = labels.includes('fast-track');
const isSemverMajor = labels.includes('semver-major');
// NOTE: a semver-major PR with fast-track should have either one of
// these labels removed because that doesn't make sense
if (isFastTracked) {
cli.info('This PR is being fast-tracked');
}
if (approved.length === 0 || requestedChanges.length > 0) {
return false;
}
if (isSemverMajor) {
const tscApproved = approved
.filter((p) => p.reviewer.isTSC())
.map((p) => p.reviewer.login);
if (tscApproved.length < 2) {
const message = 'semver-major requires at least 2 TSC approvals';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.MISSING_TSC_APPROVAL, message, {
approvals: tscApproved.length,
required: 2
});
return false; // 7 day rule doesn't matter here
}
}
let fastTrackAppendix = '';
if (isFastTracked) {
const comment = [...this.comments].reverse().find((c) =>
FAST_TRACK_RE.test(c.bodyText));
if (!comment) {
const message = 'Unable to find the fast-track request comment.';
cli.error(message);
this.addReason(
PR_CHECK_REASON_CODES.MISSING_FAST_TRACK_COMMENT,
message
);
return false;
}
const [, requester] = comment.bodyText.match(FAST_TRACK_RE);
const collaborators = Array.from(this.data.collaborators.values(),
(c) => c.login.toLowerCase());
const approvals = comment.reactions.nodes.filter((r) =>
r.user.login !== requester &&
r.user.login !== pr.author.login &&
collaborators.includes(r.user.login.toLowerCase())).length;
const missingFastTrackApprovals = FAST_TRACK_MIN_APPROVALS - approvals -
(requester === pr.author.login ? 0 : 1);
if (missingFastTrackApprovals > 0) {
isFastTracked = false;
fastTrackAppendix = ' (or 0 hours if there ' +
`${missingFastTrackApprovals === 1 ? 'is' : 'are'} ` +
`${missingFastTrackApprovals} more approval` +
`${missingFastTrackApprovals === 1 ? '' : 's'} (👍) of ` +
'the fast-track request from collaborators).';
}
}
const createTime = new Date(this.pr.createdAt);
const msFromCreateTime = now.getTime() - createTime.getTime();
const minutesFromCreateTime = msFromCreateTime / MINUTE;
const timeLeftMulti = this.waitTimeMultiApproval * 60 - minutesFromCreateTime;
const timeLeftSingle = this.waitTimeSingleApproval * 60 - minutesFromCreateTime;
const timeToText = (time, liaison_word = undefined) => {
let unity = 'minute';
if (time > 59) {
unity = 'hour';
time /= 60;
}
time = Math.ceil(time);
return `${time} ${liaison_word ? liaison_word + ' ' : ''}${unity}${time === 1 ? '' : 's'}`;
};
if (approved.length >= 2) {
if (isFastTracked) {
return true;
}
if (timeLeftMulti < 0) {
return true;
}
const message =
`This PR needs to wait ${timeToText(timeLeftMulti, 'more')} to land${fastTrackAppendix}`;
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.WAIT_TIME, message);
return false;
}
if (approved.length === 1) {
if (timeLeftSingle < 0) {
return true;
}
const message =
`This PR needs to wait ${timeToText(timeLeftSingle, 'more')} to land (or ${
timeToText(timeLeftMulti < 0 || isFastTracked ? 0 : timeLeftMulti)
} if there is one more approval)${fastTrackAppendix}`;
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.WAIT_TIME, message);
return false;
}
}
hasFullCI(ciMap) {
const cis = [...ciMap.keys()];
return cis.find(isFullCI);
}
async checkCI() {
const ciType = this.argv.ciType || CI_PROVIDERS.NODEJS;
const providers = Object.values(CI_PROVIDERS);
if (!providers.includes(ciType)) {
const message =
`Invalid ciType ${ciType} - must be one of ${providers.join(', ')}`;
this.cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.INVALID_CI_TYPE, message, {
ciType
});
return false;
}
let status = false;
if (ciType === CI_PROVIDERS.NODEJS) {
status = await this.checkNodejsCI();
} else if (ciType === CI_PROVIDERS.GITHUB) {
status = this.checkGitHubCI();
}
return status;
}
// TODO: we might want to check CI status when it's less flaky...
// TODO: not all PR requires CI...labels?
async checkJenkinsCI() {
const { cli, commits, request, argv } = this;
const { maxCommits } = argv;
const thread = this.data.getThread();
const ciMap = new JobParser(thread).parse();
let status = true;
if (!ciMap.size) {
const message = 'No Jenkins CI runs detected';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.MISSING_JENKINS_CI, message, {
provider: 'jenkins'
});
this.CIStatus = false;
return false;
} else if (!this.hasFullCI(ciMap)) {
status = false;
const message = 'No full Jenkins CI runs detected';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.MISSING_FULL_JENKINS_CI, message, {
provider: 'jenkins'
});
}
let lastCI;
for (const [type, ci] of ciMap) {
const name = CI_TYPES.get(type).name;
cli.info(`Last ${name} CI on ${ci.date}: ${ci.link}`);
if (!lastCI || lastCI.date < ci.date) {
lastCI = {
typeName: name,
date: ci.date,
jobId: ci.jobid
};
}
}
if (lastCI) {
const afterCommits = [];
commits.forEach((commit) => {
commit = commit.commit;
if (commit.committedDate > lastCI.date) {
status = false;
afterCommits.push(commit);
}
});
const totalCommits = afterCommits.length;
if (totalCommits > 0) {
const warnMsg = 'Commits were pushed after the last ' +
`${lastCI.typeName} CI run:`;
cli.warn(warnMsg);
this.addReason(PR_CHECK_REASON_CODES.STALE_CI, warnMsg, {
commits: totalCommits,
provider: 'jenkins'
});
const sliceLength = maxCommits === 0 ? totalCommits : -maxCommits;
afterCommits.slice(sliceLength)
.forEach(commit => {
cli.warn(`- ${commit.messageHeadline}`);
});
if (totalCommits > maxCommits) {
const infoMsg = '...(use `' +
`--max-commits ${totalCommits}` +
'` to see the full list of commits)';
cli.warn(infoMsg);
}
}
// Check the last CI run for its results.
const build = new PRBuild(cli, request, lastCI.jobId);
const { result, failures } = await build.getResults();
if (result === 'FAILURE') {
const message =
`${failures.length} failure(s) on the last Jenkins CI run`;
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.FAILED_JENKINS_CI, message, {
failures: failures.length,
provider: 'jenkins'
});
status = false;
// NOTE(mmarchini): not sure why PEDING returns null
} else if (result === null) {
const message = 'Last Jenkins CI still running';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.PENDING_JENKINS_CI, message, {
provider: 'jenkins'
});
status = false;
} else {
cli.ok('Last Jenkins CI successful');
}
}
this.CIStatus = status;
return status;
}
checkGitHubCI() {
const { cli, commits } = this;
if (!commits || commits.length === 0) {
const message = 'No commits detected';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.NO_COMMITS, message);
return false;
}
// NOTE(mmarchini): we only care about the last commit. Maybe in the future
// we'll want to check all commits for a successful CI.
const { commit } = commits[commits.length - 1];
this.CIStatus = false;
const checkSuites = commit.checkSuites || { nodes: [] };
if (!commit.status && checkSuites.nodes.length === 0) {
const message = 'No GitHub CI runs detected';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.MISSING_GITHUB_CI, message, {
provider: 'github'
});
return false;
}
let hasFailures = false;
const failedJobs = [];
const cancelledJobs = [];
const pendingJobs = [];
// GitHub new Check API
for (const { status, conclusion, checkRuns } of
selectLatestCheckSuites(checkSuites.nodes)) {
if (status !== 'COMPLETED') {
pendingJobs.push({ status, conclusion });
continue;
}
if (!GITHUB_SUCCESS_CONCLUSIONS.includes(conclusion)) {
const runs = checkRuns?.nodes ?? [];
const allRunsSkipped = runs.length > 0 &&
runs.length === checkRuns.totalCount &&
runs.every((checkRun) => checkRun.status === 'COMPLETED' &&
checkRun.conclusion === 'SKIPPED');
// GitHub can report a STARTUP_FAILURE for a workflow even though all
// of its conditional jobs were skipped. There is no failed check in
// that case, so the suite should not make the commit unlandable.
if (conclusion === 'STARTUP_FAILURE' && allRunsSkipped) {
continue;
}
hasFailures = true;
let reportedFailure = false;
// If we have detailed checkRuns, show specific failing jobs.
for (const checkRun of runs) {
if (checkRun.status === 'COMPLETED' &&
!GITHUB_SUCCESS_CONCLUSIONS.includes(checkRun.conclusion)) {
reportedFailure = true;
if (checkRun.conclusion === 'CANCELLED') {
cancelledJobs.push({
name: checkRun.name,
conclusion: checkRun.conclusion,
url: checkRun.detailsUrl
});
} else {
failedJobs.push({
name: checkRun.name,
conclusion: checkRun.conclusion,
url: checkRun.detailsUrl
});
}
}
}
// Fall back to the suite when its failed conclusion is not reflected
// by an individual check run.
if (!reportedFailure) {
const failures = conclusion === 'CANCELLED'
? cancelledJobs
: failedJobs;
failures.push({
name: GITHUB_ACTIONS_APP,
conclusion,
url: null
});
}
}
}
// Report pending jobs
if (pendingJobs.length > 0) {
const message = 'GitHub CI is still running';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.PENDING_GITHUB_CI, message, {
jobs: pendingJobs.length,
provider: 'github'
});
return false;
}
// Report failed jobs
if (failedJobs.length > 0) {
const message = `${failedJobs.length} GitHub CI job(s) failed:`;
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.FAILED_GITHUB_CI, message, {
jobs: failedJobs.length,
provider: 'github'
});
for (const job of failedJobs) {
const urlInfo = job.url ? ` (${job.url})` : '';
cli.error(` - ${job.name}: ${job.conclusion}${urlInfo}`);
}
}
// Report cancelled jobs
if (cancelledJobs.length > 0) {
const message = `${cancelledJobs.length} GitHub CI job(s) cancelled:`;
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.CANCELLED_GITHUB_CI, message, {
jobs: cancelledJobs.length,
provider: 'github'
});
for (const job of cancelledJobs) {
const urlInfo = job.url ? ` (${job.url})` : '';
cli.error(` - ${job.name}: ${job.conclusion}${urlInfo}`);
}
}
if (hasFailures) {
return false;
}
// GitHub old commit status API
if (commit.status) {
const { state } = commit.status;
if (state === 'PENDING') {
const message = 'GitHub CI is still running';
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.PENDING_GITHUB_CI, message, {
provider: 'github'
});
return false;
}
if (!['SUCCESS', 'EXPECTED'].includes(state)) {
const message = `GitHub CI failed with status: ${state}`;
cli.error(message);
this.addReason(PR_CHECK_REASON_CODES.FAILED_GITHUB_CI, message, {
provider: 'github',
state
});
return false;
}
}
cli.ok('Last GitHub CI successful');
this.CIStatus = true;
return true;
}
requiresJenkinsRun() {
const { pr } = this;
// NOTE(mmarchini): if files not present, fallback
// to old behavior. This should only be the case on old tests
// TODO(mmarchini): add files to all fixtures on old tests
if (!pr.files) {
return false;
}
const files = pr.files.nodes;
// Don't require Jenkins run for doc-only change.
if (files.every(({ path }) => path.endsWith('.md'))) {
return false;
}
const ciNeededFolderRx = /^(deps|lib|src|test)\//;
const ciNeededToolFolderRx =
/^tools\/(code_cache|gyp|icu|inspector|msvs|snapshot|v8_gypfiles)/;
const ciNeededFileRx = /^tools\/\.+.py$/;
const ciNeededFileList = [
'tools/build-addons.js',
'configure',
'configure.py',
'Makefile'
];
const ciNeededExtensionList = ['.gyp', '.gypi', '.bat'];
return files.some(
({ path }) =>
ciNeededFolderRx.test(path) ||
ciNeededToolFolderRx.test(path) ||
ciNeededFileRx.test(path) ||
ciNeededFileList.includes(path) ||
ciNeededExtensionList.some((ext) => path.endsWith(ext))
);
}
async checkNodejsCI() {
let status = this.checkGitHubCI();
if (
this.pr.labels.nodes.some((l) => l.name === 'needs-ci') ||
this.requiresJenkinsRun()
) {
status &= await this.checkJenkinsCI();
} else {
this.cli.info('Green GitHub CI is sufficient');
}
return status;
}
checkAuthor() {
const { cli, commits, pr } = this;
const oddCommits = this.filterOddCommits(commits);
if (!oddCommits.length) {
return true;
}
const prAuthor = `${pr.author.login}(${pr.author.email})`;
const message = `PR author is a new contributor: @${prAuthor}`;
cli.warn(message);
this.addReason(PR_CHECK_REASON_CODES.NEW_CONTRIBUTOR, message);
for (const c of oddCommits) {
const { oid, author } = c.commit;
const hash = shortSha(oid);
cli.warn(`- commit ${hash} is authored by ${author.email}`);
}
return false;
}
filterOddCommits(commits) {
return commits.filter((c) => this.isOddAuthor(c.commit));
}
isOddAuthor(commit) {
const { pr } = this;
// They have turned on the private email feature, can't really check
// anything, GitHub should know how to link that, see nodejs/node#15489
if (!pr.author.email) {
return false;
}
// If they have added the alternative email to their account,
// commit.authoredByCommitter should be set to true by Github
if (commit.authoredByCommitter) {
return false;
}
if (commit.author.email === pr.author.email) {
return false;
}
// At this point, the commit:
// 1. is not authored by the commiter i.e. author email is not in the
// committer's Github account
// 3. is not authored by the people opening the PR
return true;
}
checkGitConfig() {
const { cli, commits } = this;
for (const { commit } of commits) {
if (commit.author.user === null) {
cli.warn('GitHub cannot link the author of ' +
`'${commit.messageHeadline}' to their GitHub account.`);
cli.warn('Please suggest them to take a look at ' +
`${GIT_CONFIG_GUIDE_URL}`);
}
}
return true;
}
getApprovedTipOfHead() {
const {
commits, reviews, cli, argv
} = this;
const { maxCommits } = argv;
if (commits.length === 0) {
const message = 'No commits detected';
cli.warn(message);
this.addReason(PR_CHECK_REASON_CODES.NO_COMMITS, message);
return false;
}
const reviewIndex = reviews.findLastIndex(
review => review.authorCanPushToRepository && review.state === 'APPROVED'
);
if (reviewIndex === -1) {
const message = 'No approving reviews found';
cli.warn(message);
this.addReason(PR_CHECK_REASON_CODES.MISSING_APPROVAL, message);
return false;
}
const reviewedCommitIndex = commits
.findLastIndex(({ commit }) => commit.oid === reviews[reviewIndex].commit.oid);
if (reviewedCommitIndex !== commits.length - 1) {
const message = 'Commits were pushed since the last approving review:';
cli.warn(message);
this.addReason(PR_CHECK_REASON_CODES.STALE_REVIEW, message, {
commits: commits.length - reviewedCommitIndex - 1
});
commits.slice(Math.max(reviewedCommitIndex + 1, commits.length - maxCommits))
.forEach(({ commit }) => {
cli.warn(`- ${commit.messageHeadline}`);
});
const totalCommits = commits.length - reviewedCommitIndex - 1;
if (totalCommits > maxCommits) {
const infoMsg = '...(use `' +
`--max-commits ${totalCommits}` +
'` to see the full list of commits)';
cli.warn(infoMsg);
}
return false;
}
return reviews[reviewIndex].commit.oid;
}
checkCommitsAfterReview() {
return !!this.getApprovedTipOfHead();
}
checkMergeableState() {
const {
pr, cli
} = this;
if (pr.mergeable && pr.mergeable === CONFLICTING) {
const message = 'This PR has conflicts that must be resolved';
cli.warn(message);
this.addReason(PR_CHECK_REASON_CODES.CONFLICT, message);
return false;
}
return true;
}
checkPRState() {
const {
pr: { closed, closedAt, merged, mergedAt },
cli
} = this;
if (merged) {
const dateStr = new Date(mergedAt).toUTCString();
const message = `This PR was merged on ${dateStr}`;
cli.warn(message);
this.addReason(PR_CHECK_REASON_CODES.MERGED, message);
return false;
}
if (closed) {
const dateStr = new Date(closedAt).toUTCString();
const message = `This PR was closed on ${dateStr}`;
cli.warn(message);
this.addReason(PR_CHECK_REASON_CODES.CLOSED, message);
return false;
}
return true;
}
}