Joey Perrott 63ba74fe4e feat(dev-infra): tooling to check out pending PR (#38474)
Creates a tool within ng-dev to checkout a pending PR from the upstream repository.  This automates
an action that many developers on the Angular team need to do periodically in the process of testing
and reviewing incoming PRs.

Example usage:
  ng-dev pr checkout <pr-number>

PR Close #38474
2020-08-18 16:22:47 -07:00

161 lines
5.8 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Bar} from 'cli-progress';
import {types as graphQLTypes} from 'typed-graphqlify';
import {getConfig, NgDevConfig} from '../../utils/config';
import {error, info} from '../../utils/console';
import {GitClient} from '../../utils/git';
import {getPendingPrs} from '../../utils/github';
import {exec} from '../../utils/shelljs';
/* GraphQL schema for the response body for each pending PR. */
const PR_SCHEMA = {
headRef: {
name: graphQLTypes.string,
repository: {
url: graphQLTypes.string,
nameWithOwner: graphQLTypes.string,
},
},
baseRef: {
name: graphQLTypes.string,
repository: {
url: graphQLTypes.string,
nameWithOwner: graphQLTypes.string,
},
},
updatedAt: graphQLTypes.string,
number: graphQLTypes.number,
mergeable: graphQLTypes.string,
title: graphQLTypes.string,
};
/* Pull Request response from Github GraphQL query */
type RawPullRequest = typeof PR_SCHEMA;
/** Convert raw Pull Request response from Github to usable Pull Request object. */
function processPr(pr: RawPullRequest) {
return {...pr, updatedAt: (new Date(pr.updatedAt)).getTime()};
}
/* Pull Request object after processing, derived from the return type of the processPr function. */
type PullRequest = ReturnType<typeof processPr>;
/** Name of a temporary local branch that is used for checking conflicts. **/
const tempWorkingBranch = '__NgDevRepoBaseAfterChange__';
/** Checks if the provided PR will cause new conflicts in other pending PRs. */
export async function discoverNewConflictsForPr(
newPrNumber: number, updatedAfter: number, config: Pick<NgDevConfig, 'github'> = getConfig()) {
const git = new GitClient();
// If there are any local changes in the current repository state, the
// check cannot run as it needs to move between branches.
if (git.hasLocalChanges()) {
error('Cannot run with local changes. Please make sure there are no local changes.');
process.exit(1);
}
/** The active github branch or revision before we performed any Git commands. */
const previousBranchOrRevision = git.getCurrentBranchOrRevision();
/* Progress bar to indicate progress. */
const progressBar = new Bar({format: `[{bar}] ETA: {eta}s | {value}/{total}`});
/* PRs which were found to be conflicting. */
const conflicts: Array<PullRequest> = [];
info(`Requesting pending PRs from Github`);
/** List of PRs from github currently known as mergable. */
const allPendingPRs = (await getPendingPrs(PR_SCHEMA, git)).map(processPr);
/** The PR which is being checked against. */
const requestedPr = allPendingPRs.find(pr => pr.number === newPrNumber);
if (requestedPr === undefined) {
error(
`The request PR, #${newPrNumber} was not found as a pending PR on github, please confirm`);
error(`the PR number is correct and is an open PR`);
process.exit(1);
}
const pendingPrs = allPendingPRs.filter(pr => {
return (
// PRs being merged into the same target branch as the requested PR
pr.baseRef.name === requestedPr.baseRef.name &&
// PRs which either have not been processed or are determined as mergable by Github
pr.mergeable !== 'CONFLICTING' &&
// PRs updated after the provided date
pr.updatedAt >= updatedAfter);
});
info(`Retrieved ${allPendingPRs.length} total pending PRs`);
info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`);
// Fetch and checkout the PR being checked.
exec(`git fetch ${requestedPr.headRef.repository.url} ${requestedPr.headRef.name}`);
exec(`git checkout -B ${tempWorkingBranch} FETCH_HEAD`);
// Rebase the PR against the PRs target branch.
exec(`git fetch ${requestedPr.baseRef.repository.url} ${requestedPr.baseRef.name}`);
const result = exec(`git rebase FETCH_HEAD`);
if (result.code) {
error('The requested PR currently has conflicts');
cleanUpGitState(previousBranchOrRevision);
process.exit(1);
}
// Start the progress bar
progressBar.start(pendingPrs.length, 0);
// Check each PR to determine if it can merge cleanly into the repo after the target PR.
for (const pr of pendingPrs) {
// Fetch and checkout the next PR
exec(`git fetch ${pr.headRef.repository.url} ${pr.headRef.name}`);
exec(`git checkout --detach FETCH_HEAD`);
// Check if the PR cleanly rebases into the repo after the target PR.
const result = exec(`git rebase ${tempWorkingBranch}`);
if (result.code !== 0) {
conflicts.push(pr);
}
// Abort any outstanding rebase attempt.
exec(`git rebase --abort`);
progressBar.increment(1);
}
// End the progress bar as all PRs have been processed.
progressBar.stop();
info();
info(`Result:`);
cleanUpGitState(previousBranchOrRevision);
// If no conflicts are found, exit successfully.
if (conflicts.length === 0) {
info(`No new conflicting PRs found after #${newPrNumber} merging`);
process.exit(0);
}
// Inform about discovered conflicts, exit with failure.
error.group(`${conflicts.length} PR(s) which conflict(s) after #${newPrNumber} merges:`);
for (const pr of conflicts) {
error(` - #${pr.number}: ${pr.title}`);
}
error.groupEnd();
process.exit(1);
}
/** Reset git back to the provided branch or revision. */
export function cleanUpGitState(previousBranchOrRevision: string) {
// Ensure that any outstanding rebases are aborted.
exec(`git rebase --abort`);
// Ensure that any changes in the current repo state are cleared.
exec(`git reset --hard`);
// Checkout the original branch from before the run began.
exec(`git checkout ${previousBranchOrRevision}`);
// Delete the generated branch.
exec(`git branch -D ${tempWorkingBranch}`);
}