Skip to content

Commit 0912be4

Browse files
josephperrottkara
authored andcommitted
feat(dev-infra): create tool to determine conflicts created by a PR (angular#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 angular#37051
1 parent 9548c7c commit 0912be4

File tree

10 files changed

+358
-0
lines changed

10 files changed

+358
-0
lines changed

dev-infra/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ts_library(
1010
deps = [
1111
"//dev-infra/commit-message",
1212
"//dev-infra/format",
13+
"//dev-infra/pr",
1314
"//dev-infra/pullapprove",
1415
"//dev-infra/release",
1516
"//dev-infra/ts-circular-dependencies",

dev-infra/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {buildPullapproveParser} from './pullapprove/cli';
1212
import {buildCommitMessageParser} from './commit-message/cli';
1313
import {buildFormatParser} from './format/cli';
1414
import {buildReleaseParser} from './release/cli';
15+
import {buildPrParser} from './pr/cli';
1516

1617
yargs.scriptName('ng-dev')
1718
.demandCommand()
1819
.recommendCommands()
1920
.command('commit-message <command>', '', buildCommitMessageParser)
2021
.command('format <command>', '', buildFormatParser)
22+
.command('pr <command>', '', buildPrParser)
2123
.command('pullapprove <command>', '', buildPullapproveParser)
2224
.command('release <command>', '', buildReleaseParser)
2325
.command('ts-circular-deps <command>', '', tsCircularDependenciesBuilder)

dev-infra/pr/BUILD.bazel

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
load("@npm_bazel_typescript//:index.bzl", "ts_library")
2+
3+
ts_library(
4+
name = "pr",
5+
srcs = glob([
6+
"*.ts",
7+
]),
8+
module_name = "@angular/dev-infra-private/pr",
9+
visibility = ["//dev-infra:__subpackages__"],
10+
deps = [
11+
"//dev-infra/utils",
12+
"@npm//@types/cli-progress",
13+
"@npm//@types/node",
14+
"@npm//@types/shelljs",
15+
"@npm//@types/yargs",
16+
"@npm//cli-progress",
17+
"@npm//shelljs",
18+
"@npm//typed-graphqlify",
19+
"@npm//yargs",
20+
],
21+
)

dev-infra/pr/cli.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as yargs from 'yargs';
10+
import {discoverNewConflictsForPr} from './discover-new-conflicts';
11+
12+
/** A Date object 30 days ago. */
13+
const THIRTY_DAYS_AGO = (() => {
14+
const date = new Date();
15+
// Set the hours, minutes and seconds to 0 to only consider date.
16+
date.setHours(0, 0, 0, 0);
17+
// Set the date to 30 days in the past.
18+
date.setDate(date.getDate() - 30);
19+
return date;
20+
})();
21+
22+
/** Build the parser for the pr commands. */
23+
export function buildPrParser(localYargs: yargs.Argv) {
24+
return localYargs.help().strict().demandCommand().command(
25+
'discover-new-conflicts <pr>',
26+
'Check if a pending PR causes new conflicts for other pending PRs',
27+
args => {
28+
return args.option('date', {
29+
description: 'Only consider PRs updated since provided date',
30+
defaultDescription: '30 days ago',
31+
coerce: Date.parse,
32+
default: THIRTY_DAYS_AGO,
33+
});
34+
},
35+
({pr, date}) => {
36+
// If a provided date is not able to be parsed, yargs provides it as NaN.
37+
if (isNaN(date)) {
38+
console.error('Unable to parse the value provided via --date flag');
39+
process.exit(1);
40+
}
41+
discoverNewConflictsForPr(pr, date);
42+
});
43+
}
44+
45+
if (require.main === module) {
46+
buildPrParser(yargs).parse();
47+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Bar} from 'cli-progress';
10+
import {types as graphQLTypes} from 'typed-graphqlify';
11+
12+
import {getConfig, NgDevConfig} from '../utils/config';
13+
import {getCurrentBranch, hasLocalChanges} from '../utils/git';
14+
import {getPendingPrs} from '../utils/github';
15+
import {exec} from '../utils/shelljs';
16+
17+
18+
/* GraphQL schema for the response body for each pending PR. */
19+
const PR_SCHEMA = {
20+
headRef: {
21+
name: graphQLTypes.string,
22+
repository: {
23+
url: graphQLTypes.string,
24+
nameWithOwner: graphQLTypes.string,
25+
},
26+
},
27+
baseRef: {
28+
name: graphQLTypes.string,
29+
repository: {
30+
url: graphQLTypes.string,
31+
nameWithOwner: graphQLTypes.string,
32+
},
33+
},
34+
updatedAt: graphQLTypes.string,
35+
number: graphQLTypes.number,
36+
mergeable: graphQLTypes.string,
37+
title: graphQLTypes.string,
38+
};
39+
40+
/* Pull Request response from Github GraphQL query */
41+
type RawPullRequest = typeof PR_SCHEMA;
42+
43+
/** Convert raw Pull Request response from Github to usable Pull Request object. */
44+
function processPr(pr: RawPullRequest) {
45+
return {...pr, updatedAt: (new Date(pr.updatedAt)).getTime()};
46+
}
47+
48+
/* Pull Request object after processing, derived from the return type of the processPr function. */
49+
type PullRequest = ReturnType<typeof processPr>;
50+
51+
/** Name of a temporary local branch that is used for checking conflicts. **/
52+
const tempWorkingBranch = '__NgDevRepoBaseAfterChange__';
53+
54+
/** Checks if the provided PR will cause new conflicts in other pending PRs. */
55+
export async function discoverNewConflictsForPr(
56+
newPrNumber: number, updatedAfter: number, config: Pick<NgDevConfig, 'github'> = getConfig()) {
57+
// If there are any local changes in the current repository state, the
58+
// check cannot run as it needs to move between branches.
59+
if (hasLocalChanges()) {
60+
console.error('Cannot run with local changes. Please make sure there are no local changes.');
61+
process.exit(1);
62+
}
63+
64+
/** The active github branch when the run began. */
65+
const originalBranch = getCurrentBranch();
66+
/* Progress bar to indicate progress. */
67+
const progressBar = new Bar({format: `[{bar}] ETA: {eta}s | {value}/{total}`});
68+
/* PRs which were found to be conflicting. */
69+
const conflicts: Array<PullRequest> = [];
70+
/* String version of the updatedAfter value, for logging. */
71+
const updatedAfterString = new Date(updatedAfter).toLocaleDateString();
72+
73+
console.info(`Requesting pending PRs from Github`);
74+
/** List of PRs from github currently known as mergable. */
75+
const allPendingPRs = (await getPendingPrs(PR_SCHEMA, config.github)).map(processPr);
76+
/** The PR which is being checked against. */
77+
const requestedPr = allPendingPRs.find(pr => pr.number === newPrNumber);
78+
if (requestedPr === undefined) {
79+
console.error(
80+
`The request PR, #${newPrNumber} was not found as a pending PR on github, please confirm`);
81+
console.error(`the PR number is correct and is an open PR`);
82+
process.exit(1);
83+
}
84+
85+
const pendingPrs = allPendingPRs.filter(pr => {
86+
return (
87+
// PRs being merged into the same target branch as the requested PR
88+
pr.baseRef.name === requestedPr.baseRef.name &&
89+
// PRs which either have not been processed or are determined as mergable by Github
90+
pr.mergeable !== 'CONFLICTING' &&
91+
// PRs updated after the provided date
92+
pr.updatedAt >= updatedAfter);
93+
});
94+
console.info(`Retrieved ${allPendingPRs.length} total pending PRs`);
95+
console.info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`);
96+
97+
// Fetch and checkout the PR being checked.
98+
exec(`git fetch ${requestedPr.headRef.repository.url} ${requestedPr.headRef.name}`);
99+
exec(`git checkout -B ${tempWorkingBranch} FETCH_HEAD`);
100+
101+
// Rebase the PR against the PRs target branch.
102+
exec(`git fetch ${requestedPr.baseRef.repository.url} ${requestedPr.baseRef.name}`);
103+
const result = exec(`git rebase FETCH_HEAD`);
104+
if (result.code) {
105+
console.error('The requested PR currently has conflicts');
106+
cleanUpGitState(originalBranch);
107+
process.exit(1);
108+
}
109+
110+
// Start the progress bar
111+
progressBar.start(pendingPrs.length, 0);
112+
113+
// Check each PR to determine if it can merge cleanly into the repo after the target PR.
114+
for (const pr of pendingPrs) {
115+
// Fetch and checkout the next PR
116+
exec(`git fetch ${pr.headRef.repository.url} ${pr.headRef.name}`);
117+
exec(`git checkout --detach FETCH_HEAD`);
118+
// Check if the PR cleanly rebases into the repo after the target PR.
119+
const result = exec(`git rebase ${tempWorkingBranch}`);
120+
if (result.code !== 0) {
121+
conflicts.push(pr);
122+
}
123+
// Abort any outstanding rebase attempt.
124+
exec(`git rebase --abort`);
125+
126+
progressBar.increment(1);
127+
}
128+
// End the progress bar as all PRs have been processed.
129+
progressBar.stop();
130+
console.info(`\nResult:`);
131+
132+
cleanUpGitState(originalBranch);
133+
134+
// If no conflicts are found, exit successfully.
135+
if (conflicts.length === 0) {
136+
console.info(`No new conflicting PRs found after #${newPrNumber} merging`);
137+
process.exit(0);
138+
}
139+
140+
// Inform about discovered conflicts, exit with failure.
141+
console.error(`${conflicts.length} PR(s) which conflict(s) after #${newPrNumber} merges:`);
142+
for (const pr of conflicts) {
143+
console.error(` - ${pr.number}: ${pr.title}`);
144+
}
145+
process.exit(1);
146+
}
147+
148+
/** Reset git back to the provided branch. */
149+
export function cleanUpGitState(branch: string) {
150+
// Ensure that any outstanding rebases are aborted.
151+
exec(`git rebase --abort`);
152+
// Ensure that any changes in the current repo state are cleared.
153+
exec(`git reset --hard`);
154+
// Checkout the original branch from before the run began.
155+
exec(`git checkout ${branch}`);
156+
// Delete the generated branch.
157+
exec(`git branch -D ${tempWorkingBranch}`);
158+
}

dev-infra/tmpl-package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
"ts-circular-deps": "./ts-circular-dependencies/index.js"
1010
},
1111
"dependencies": {
12+
"@octokit/graphql": "<from-root>",
1213
"chalk": "<from-root>",
1314
"cli-progress": "<from-root>",
1415
"glob": "<from-root>",
1516
"inquirer": "<from-root>",
1617
"minimatch": "<from-root>",
1718
"multimatch": "<from-root>",
1819
"shelljs": "<from-root>",
20+
"typed-graphqlify": "<from-root>",
1921
"yaml": "<from-root>",
2022
"yargs": "<from-root>"
2123
},

dev-infra/utils/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ ts_library(
66
module_name = "@angular/dev-infra-private/utils",
77
visibility = ["//dev-infra:__subpackages__"],
88
deps = [
9+
"@npm//@octokit/graphql",
910
"@npm//@types/node",
1011
"@npm//@types/shelljs",
1112
"@npm//shelljs",
1213
"@npm//tslib",
14+
"@npm//typed-graphqlify",
1315
],
1416
)

dev-infra/utils/git.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {exec} from '../utils/shelljs';
10+
11+
12+
/** Whether the repo has any local changes. */
13+
export function hasLocalChanges() {
14+
return !!exec(`git status --porcelain`).trim();
15+
}
16+
17+
/** Get the currently checked out branch. */
18+
export function getCurrentBranch() {
19+
return exec(`git symbolic-ref --short HEAD`).trim();
20+
}

dev-infra/utils/github.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {graphql as unauthenticatedGraphql} from '@octokit/graphql';
10+
11+
import {params, query as graphqlQuery, types} from 'typed-graphqlify';
12+
import {NgDevConfig} from './config';
13+
14+
/** The configuration required for github interactions. */
15+
type GithubConfig = NgDevConfig['github'];
16+
17+
/**
18+
* Authenticated instance of Github GraphQl API service, relies on a
19+
* personal access token being available in the TOKEN environment variable.
20+
*/
21+
const graphql = unauthenticatedGraphql.defaults({
22+
headers: {
23+
// TODO(josephperrott): Remove reference to TOKEN environment variable as part of larger
24+
// effort to migrate to expecting tokens via GITHUB_ACCESS_TOKEN environment variables.
25+
authorization: `token ${process.env.TOKEN || process.env.GITHUB_ACCESS_TOKEN}`,
26+
}
27+
});
28+
29+
/** Get all pending PRs from github */
30+
export async function getPendingPrs<PrSchema>(prSchema: PrSchema, {owner, name}: GithubConfig) {
31+
// The GraphQL query object to get a page of pending PRs
32+
const PRS_QUERY = params(
33+
{
34+
$first: 'Int', // How many entries to get with each request
35+
$after: 'String', // The cursor to start the page at
36+
$owner: 'String!', // The organization to query for
37+
$name: 'String!', // The repository to query for
38+
},
39+
{
40+
repository: params({owner: '$owner', name: '$name'}, {
41+
pullRequests: params(
42+
{
43+
first: '$first',
44+
after: '$after',
45+
states: `OPEN`,
46+
},
47+
{
48+
nodes: [prSchema],
49+
pageInfo: {
50+
hasNextPage: types.boolean,
51+
endCursor: types.string,
52+
},
53+
}),
54+
})
55+
});
56+
const query = graphqlQuery('members', PRS_QUERY);
57+
58+
/**
59+
* Gets the query and queryParams for a specific page of entries.
60+
*/
61+
const queryBuilder = (count: number, cursor?: string) => {
62+
return {
63+
query,
64+
params: {
65+
after: cursor || null,
66+
first: count,
67+
owner,
68+
name,
69+
},
70+
};
71+
};
72+
73+
// The current cursor
74+
let cursor: string|undefined;
75+
// If an additional page of members is expected
76+
let hasNextPage = true;
77+
// Array of pending PRs
78+
const prs: Array<PrSchema> = [];
79+
80+
// For each page of the response, get the page and add it to the
81+
// list of PRs
82+
while (hasNextPage) {
83+
const {query, params} = queryBuilder(100, cursor);
84+
const results = await graphql(query, params) as typeof PRS_QUERY;
85+
86+
prs.push(...results.repository.pullRequests.nodes);
87+
hasNextPage = results.repository.pullRequests.pageInfo.hasNextPage;
88+
cursor = results.repository.pullRequests.pageInfo.endCursor;
89+
}
90+
return prs;
91+
}

0 commit comments

Comments
 (0)