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:
parent
55c2433171
commit
3d7c85b2aa
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
)
|
|
@ -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();
|
||||
}
|
|
@ -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}`);
|
||||
}
|
|
@ -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>"
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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});
|
||||
}
|
Loading…
Reference in New Issue