feat(dev-infra): create tool to determine conflicts created by a PR (#37051)

Creates a tool in ng-dev to determine the PRs which become conflicted
by merging a specified PR.  Often the question is brought up of how
many PRs require a rebase as a result of a change.  This script allows
to determine this impact.

PR Close #37051
This commit is contained in:
Joey Perrott 2020-05-05 15:37:31 -07:00 committed by Kara Erickson
parent 55c2433171
commit 3d7c85b2aa
10 changed files with 358 additions and 0 deletions

View File

@ -10,6 +10,7 @@ ts_library(
deps = [
"//dev-infra/commit-message",
"//dev-infra/format",
"//dev-infra/pr",
"//dev-infra/pullapprove",
"//dev-infra/release",
"//dev-infra/ts-circular-dependencies",

View File

@ -12,12 +12,14 @@ import {buildPullapproveParser} from './pullapprove/cli';
import {buildCommitMessageParser} from './commit-message/cli';
import {buildFormatParser} from './format/cli';
import {buildReleaseParser} from './release/cli';
import {buildPrParser} from './pr/cli';
yargs.scriptName('ng-dev')
.demandCommand()
.recommendCommands()
.command('commit-message <command>', '', buildCommitMessageParser)
.command('format <command>', '', buildFormatParser)
.command('pr <command>', '', buildPrParser)
.command('pullapprove <command>', '', buildPullapproveParser)
.command('release <command>', '', buildReleaseParser)
.command('ts-circular-deps <command>', '', tsCircularDependenciesBuilder)

21
dev-infra/pr/BUILD.bazel Normal file
View File

@ -0,0 +1,21 @@
load("@npm_bazel_typescript//:index.bzl", "ts_library")
ts_library(
name = "pr",
srcs = glob([
"*.ts",
]),
module_name = "@angular/dev-infra-private/pr",
visibility = ["//dev-infra:__subpackages__"],
deps = [
"//dev-infra/utils",
"@npm//@types/cli-progress",
"@npm//@types/node",
"@npm//@types/shelljs",
"@npm//@types/yargs",
"@npm//cli-progress",
"@npm//shelljs",
"@npm//typed-graphqlify",
"@npm//yargs",
],
)

47
dev-infra/pr/cli.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* @license
* Copyright Google Inc. 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 * as yargs from 'yargs';
import {discoverNewConflictsForPr} from './discover-new-conflicts';
/** A Date object 30 days ago. */
const THIRTY_DAYS_AGO = (() => {
const date = new Date();
// Set the hours, minutes and seconds to 0 to only consider date.
date.setHours(0, 0, 0, 0);
// Set the date to 30 days in the past.
date.setDate(date.getDate() - 30);
return date;
})();
/** Build the parser for the pr commands. */
export function buildPrParser(localYargs: yargs.Argv) {
return localYargs.help().strict().demandCommand().command(
'discover-new-conflicts <pr>',
'Check if a pending PR causes new conflicts for other pending PRs',
args => {
return args.option('date', {
description: 'Only consider PRs updated since provided date',
defaultDescription: '30 days ago',
coerce: Date.parse,
default: THIRTY_DAYS_AGO,
});
},
({pr, date}) => {
// If a provided date is not able to be parsed, yargs provides it as NaN.
if (isNaN(date)) {
console.error('Unable to parse the value provided via --date flag');
process.exit(1);
}
discoverNewConflictsForPr(pr, date);
});
}
if (require.main === module) {
buildPrParser(yargs).parse();
}

View File

@ -0,0 +1,158 @@
/**
* @license
* Copyright Google Inc. 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 {getCurrentBranch, hasLocalChanges} 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()) {
// If there are any local changes in the current repository state, the
// check cannot run as it needs to move between branches.
if (hasLocalChanges()) {
console.error('Cannot run with local changes. Please make sure there are no local changes.');
process.exit(1);
}
/** The active github branch when the run began. */
const originalBranch = getCurrentBranch();
/* 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> = [];
/* String version of the updatedAfter value, for logging. */
const updatedAfterString = new Date(updatedAfter).toLocaleDateString();
console.info(`Requesting pending PRs from Github`);
/** List of PRs from github currently known as mergable. */
const allPendingPRs = (await getPendingPrs(PR_SCHEMA, config.github)).map(processPr);
/** The PR which is being checked against. */
const requestedPr = allPendingPRs.find(pr => pr.number === newPrNumber);
if (requestedPr === undefined) {
console.error(
`The request PR, #${newPrNumber} was not found as a pending PR on github, please confirm`);
console.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);
});
console.info(`Retrieved ${allPendingPRs.length} total pending PRs`);
console.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) {
console.error('The requested PR currently has conflicts');
cleanUpGitState(originalBranch);
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();
console.info(`\nResult:`);
cleanUpGitState(originalBranch);
// If no conflicts are found, exit successfully.
if (conflicts.length === 0) {
console.info(`No new conflicting PRs found after #${newPrNumber} merging`);
process.exit(0);
}
// Inform about discovered conflicts, exit with failure.
console.error(`${conflicts.length} PR(s) which conflict(s) after #${newPrNumber} merges:`);
for (const pr of conflicts) {
console.error(` - ${pr.number}: ${pr.title}`);
}
process.exit(1);
}
/** Reset git back to the provided branch. */
export function cleanUpGitState(branch: 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 ${branch}`);
// Delete the generated branch.
exec(`git branch -D ${tempWorkingBranch}`);
}

View File

@ -9,6 +9,7 @@
"ts-circular-deps": "./ts-circular-dependencies/index.js"
},
"dependencies": {
"@octokit/graphql": "<from-root>",
"chalk": "<from-root>",
"cli-progress": "<from-root>",
"glob": "<from-root>",
@ -16,6 +17,7 @@
"minimatch": "<from-root>",
"multimatch": "<from-root>",
"shelljs": "<from-root>",
"typed-graphqlify": "<from-root>",
"yaml": "<from-root>",
"yargs": "<from-root>"
},

View File

@ -6,9 +6,11 @@ ts_library(
module_name = "@angular/dev-infra-private/utils",
visibility = ["//dev-infra:__subpackages__"],
deps = [
"@npm//@octokit/graphql",
"@npm//@types/node",
"@npm//@types/shelljs",
"@npm//shelljs",
"@npm//tslib",
"@npm//typed-graphqlify",
],
)

20
dev-infra/utils/git.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* @license
* Copyright Google Inc. 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 {exec} from '../utils/shelljs';
/** Whether the repo has any local changes. */
export function hasLocalChanges() {
return !!exec(`git status --porcelain`).trim();
}
/** Get the currently checked out branch. */
export function getCurrentBranch() {
return exec(`git symbolic-ref --short HEAD`).trim();
}

91
dev-infra/utils/github.ts Normal file
View File

@ -0,0 +1,91 @@
/**
* @license
* Copyright Google Inc. 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 {graphql as unauthenticatedGraphql} from '@octokit/graphql';
import {params, query as graphqlQuery, types} from 'typed-graphqlify';
import {NgDevConfig} from './config';
/** The configuration required for github interactions. */
type GithubConfig = NgDevConfig['github'];
/**
* Authenticated instance of Github GraphQl API service, relies on a
* personal access token being available in the TOKEN environment variable.
*/
const graphql = unauthenticatedGraphql.defaults({
headers: {
// TODO(josephperrott): Remove reference to TOKEN environment variable as part of larger
// effort to migrate to expecting tokens via GITHUB_ACCESS_TOKEN environment variables.
authorization: `token ${process.env.TOKEN || process.env.GITHUB_ACCESS_TOKEN}`,
}
});
/** Get all pending PRs from github */
export async function getPendingPrs<PrSchema>(prSchema: PrSchema, {owner, name}: GithubConfig) {
// The GraphQL query object to get a page of pending PRs
const PRS_QUERY = params(
{
$first: 'Int', // How many entries to get with each request
$after: 'String', // The cursor to start the page at
$owner: 'String!', // The organization to query for
$name: 'String!', // The repository to query for
},
{
repository: params({owner: '$owner', name: '$name'}, {
pullRequests: params(
{
first: '$first',
after: '$after',
states: `OPEN`,
},
{
nodes: [prSchema],
pageInfo: {
hasNextPage: types.boolean,
endCursor: types.string,
},
}),
})
});
const query = graphqlQuery('members', PRS_QUERY);
/**
* Gets the query and queryParams for a specific page of entries.
*/
const queryBuilder = (count: number, cursor?: string) => {
return {
query,
params: {
after: cursor || null,
first: count,
owner,
name,
},
};
};
// The current cursor
let cursor: string|undefined;
// If an additional page of members is expected
let hasNextPage = true;
// Array of pending PRs
const prs: Array<PrSchema> = [];
// For each page of the response, get the page and add it to the
// list of PRs
while (hasNextPage) {
const {query, params} = queryBuilder(100, cursor);
const results = await graphql(query, params) as typeof PRS_QUERY;
prs.push(...results.repository.pullRequests.nodes);
hasNextPage = results.repository.pullRequests.pageInfo.hasNextPage;
cursor = results.repository.pullRequests.pageInfo.endCursor;
}
return prs;
}

View File

@ -0,0 +1,14 @@
/**
* @license
* Copyright Google Inc. 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 {exec as _exec, ShellString} from 'shelljs';
/* Run an exec command as silent. */
export function exec(cmd: string): ShellString {
return _exec(cmd, {silent: true});
}