diff --git a/dev-infra/BUILD.bazel b/dev-infra/BUILD.bazel index 44a7a2930b..16d1f66ff4 100644 --- a/dev-infra/BUILD.bazel +++ b/dev-infra/BUILD.bazel @@ -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", diff --git a/dev-infra/cli.ts b/dev-infra/cli.ts index 836b90afa9..f06e210f8f 100644 --- a/dev-infra/cli.ts +++ b/dev-infra/cli.ts @@ -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 ', '', buildCommitMessageParser) .command('format ', '', buildFormatParser) + .command('pr ', '', buildPrParser) .command('pullapprove ', '', buildPullapproveParser) .command('release ', '', buildReleaseParser) .command('ts-circular-deps ', '', tsCircularDependenciesBuilder) diff --git a/dev-infra/pr/BUILD.bazel b/dev-infra/pr/BUILD.bazel new file mode 100644 index 0000000000..ad1765c96e --- /dev/null +++ b/dev-infra/pr/BUILD.bazel @@ -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", + ], +) diff --git a/dev-infra/pr/cli.ts b/dev-infra/pr/cli.ts new file mode 100644 index 0000000000..a27d39eb02 --- /dev/null +++ b/dev-infra/pr/cli.ts @@ -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 ', + '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(); +} diff --git a/dev-infra/pr/discover-new-conflicts.ts b/dev-infra/pr/discover-new-conflicts.ts new file mode 100644 index 0000000000..3f5d0f9474 --- /dev/null +++ b/dev-infra/pr/discover-new-conflicts.ts @@ -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; + +/** 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 = 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 = []; + /* 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}`); +} diff --git a/dev-infra/tmpl-package.json b/dev-infra/tmpl-package.json index 4d18d6ad8e..e1ffd09573 100644 --- a/dev-infra/tmpl-package.json +++ b/dev-infra/tmpl-package.json @@ -9,6 +9,7 @@ "ts-circular-deps": "./ts-circular-dependencies/index.js" }, "dependencies": { + "@octokit/graphql": "", "chalk": "", "cli-progress": "", "glob": "", @@ -16,6 +17,7 @@ "minimatch": "", "multimatch": "", "shelljs": "", + "typed-graphqlify": "", "yaml": "", "yargs": "" }, diff --git a/dev-infra/utils/BUILD.bazel b/dev-infra/utils/BUILD.bazel index 0610a5469f..fe3c27e477 100644 --- a/dev-infra/utils/BUILD.bazel +++ b/dev-infra/utils/BUILD.bazel @@ -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", ], ) diff --git a/dev-infra/utils/git.ts b/dev-infra/utils/git.ts new file mode 100644 index 0000000000..da7f576fc6 --- /dev/null +++ b/dev-infra/utils/git.ts @@ -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(); +} diff --git a/dev-infra/utils/github.ts b/dev-infra/utils/github.ts new file mode 100644 index 0000000000..127e9f14e3 --- /dev/null +++ b/dev-infra/utils/github.ts @@ -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, {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 = []; + + // 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; +} diff --git a/dev-infra/utils/shelljs.ts b/dev-infra/utils/shelljs.ts new file mode 100644 index 0000000000..39fdb56930 --- /dev/null +++ b/dev-infra/utils/shelljs.ts @@ -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}); +}