From d0dd0e80f67739575e17c93171abdaf5639d4200 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Wed, 28 Oct 2020 17:14:40 -0700 Subject: [PATCH] feat(dev-infra): provide tooling to check what branches a pr targets (#39504) Create a command in the `ng-dev` toolset that allows user's to check what branches a PR will merge into based on its targeting. PR Close #39504 --- dev-infra/ng-dev.js | 412 +++++++++++------- dev-infra/pr/BUILD.bazel | 1 + .../pr/check-target-branches/BUILD.bazel | 13 + .../check-target-branches.ts | 52 +++ dev-infra/pr/check-target-branches/cli.ts | 44 ++ dev-infra/pr/cli.ts | 4 +- 6 files changed, 360 insertions(+), 166 deletions(-) create mode 100644 dev-infra/pr/check-target-branches/BUILD.bazel create mode 100644 dev-infra/pr/check-target-branches/check-target-branches.ts create mode 100644 dev-infra/pr/check-target-branches/cli.ts diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js index 3f1e56d27b..a84314d67d 100755 --- a/dev-infra/ng-dev.js +++ b/dev-infra/ng-dev.js @@ -2599,6 +2599,235 @@ function buildNgbotParser(localYargs) { return localYargs.help().strict().demandCommand().command('verify', 'Verify the NgBot config', {}, () => verify()); } +/** + * @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 + */ +/** Loads and validates the merge configuration. */ +function loadAndValidateConfig(config, api) { + return tslib.__awaiter(this, void 0, void 0, function () { + var mergeConfig, errors; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + if (config.merge === undefined) { + return [2 /*return*/, { errors: ['No merge configuration found. Set the `merge` configuration.'] }]; + } + if (typeof config.merge !== 'function') { + return [2 /*return*/, { errors: ['Expected merge configuration to be defined lazily through a function.'] }]; + } + return [4 /*yield*/, config.merge(api)]; + case 1: + mergeConfig = _a.sent(); + errors = validateMergeConfig(mergeConfig); + if (errors.length) { + return [2 /*return*/, { errors: errors }]; + } + return [2 /*return*/, { config: mergeConfig }]; + } + }); + }); +} +/** Validates the specified configuration. Returns a list of failure messages. */ +function validateMergeConfig(config) { + var errors = []; + if (!config.labels) { + errors.push('No label configuration.'); + } + else if (!Array.isArray(config.labels)) { + errors.push('Label configuration needs to be an array.'); + } + if (!config.claSignedLabel) { + errors.push('No CLA signed label configured.'); + } + if (!config.mergeReadyLabel) { + errors.push('No merge ready label configured.'); + } + if (config.githubApiMerge === undefined) { + errors.push('No explicit choice of merge strategy. Please set `githubApiMerge`.'); + } + return errors; +} + +/** + * @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 + */ +/** Checks whether the specified value matches the given pattern. */ +function matchesPattern(value, pattern) { + return typeof pattern === 'string' ? value === pattern : pattern.test(value); +} + +/** + * @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 + */ +/** + * Unique error that can be thrown in the merge configuration if an + * invalid branch is targeted. + */ +var InvalidTargetBranchError = /** @class */ (function () { + function InvalidTargetBranchError(failureMessage) { + this.failureMessage = failureMessage; + } + return InvalidTargetBranchError; +}()); +/** + * Unique error that can be thrown in the merge configuration if an + * invalid label has been applied to a pull request. + */ +var InvalidTargetLabelError = /** @class */ (function () { + function InvalidTargetLabelError(failureMessage) { + this.failureMessage = failureMessage; + } + return InvalidTargetLabelError; +}()); +/** Gets the target label from the specified pull request labels. */ +function getTargetLabelFromPullRequest(config, labels) { + var e_1, _a; + var _loop_1 = function (label) { + var match = config.labels.find(function (_a) { + var pattern = _a.pattern; + return matchesPattern(label, pattern); + }); + if (match !== undefined) { + return { value: match }; + } + }; + try { + for (var labels_1 = tslib.__values(labels), labels_1_1 = labels_1.next(); !labels_1_1.done; labels_1_1 = labels_1.next()) { + var label = labels_1_1.value; + var state_1 = _loop_1(label); + if (typeof state_1 === "object") + return state_1.value; + } + } + catch (e_1_1) { e_1 = { error: e_1_1 }; } + finally { + try { + if (labels_1_1 && !labels_1_1.done && (_a = labels_1.return)) _a.call(labels_1); + } + finally { if (e_1) throw e_1.error; } + } + return null; +} +/** + * Gets the branches from the specified target label. + * + * @throws {InvalidTargetLabelError} Invalid label has been applied to pull request. + * @throws {InvalidTargetBranchError} Invalid Github target branch has been selected. + */ +function getBranchesFromTargetLabel(label, githubTargetBranch) { + return tslib.__awaiter(this, void 0, void 0, function () { + var _a; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!(typeof label.branches === 'function')) return [3 /*break*/, 2]; + return [4 /*yield*/, label.branches(githubTargetBranch)]; + case 1: + _a = _b.sent(); + return [3 /*break*/, 4]; + case 2: return [4 /*yield*/, label.branches]; + case 3: + _a = _b.sent(); + _b.label = 4; + case 4: return [2 /*return*/, _a]; + } + }); + }); +} + +/** + * @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 + */ +function checkTargetBranchesForPr(prNumber, jsonOutput = false) { + return tslib.__awaiter(this, void 0, void 0, function* () { + /** The ng-dev configuration. */ + const config = getConfig(); + /** Repo owner and name for the github repository. */ + const { owner, name: repo } = config.github; + /** The git client to get a Github API service instance. */ + const git = new GitClient(undefined, config); + /** The validated merge config. */ + const { config: mergeConfig, errors } = yield loadAndValidateConfig(config, git.github); + if (errors !== undefined) { + throw Error(`Invalid configuration found: ${errors}`); + } + /** The current state of the pull request from Github. */ + const prData = (yield git.github.pulls.get({ owner, repo, pull_number: prNumber })).data; + /** The list of labels on the PR as strings. */ + const labels = prData.labels.map(l => l.name); + /** The branch targetted via the Github UI. */ + const githubTargetBranch = prData.base.ref; + /** The active label which is being used for targetting the PR. */ + const targetLabel = getTargetLabelFromPullRequest(mergeConfig, labels); + if (targetLabel === null) { + error(red(`No target label was found on pr #${prNumber}`)); + process.exitCode = 1; + return; + } + /** The target branches based on the target label and branch targetted in the Github UI. */ + const targets = yield getBranchesFromTargetLabel(targetLabel, githubTargetBranch); + // When requested, print a json output to stdout, rather than using standard ng-dev logging. + if (jsonOutput) { + process.stdout.write(JSON.stringify(targets)); + return; + } + info.group(`PR #${prNumber} will merge into:`); + targets.forEach(target => info(`- ${target}`)); + info.groupEnd(); + }); +} + +/** + * @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 + */ +/** Builds the command. */ +function builder$5(yargs) { + return yargs + .positional('pr', { + description: 'The pull request number', + type: 'number', + demandOption: true, + }) + .option('json', { + type: 'boolean', + default: false, + description: 'Print response as json', + }); +} +/** Handles the command. */ +function handler$5({ pr, json }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + yield checkTargetBranchesForPr(pr, json); + }); +} +/** yargs command module describing the command. */ +const CheckTargetBranchesModule = { + handler: handler$5, + builder: builder$5, + command: 'check-target-branches ', + describe: 'Check a PR to determine what branches it is currently targeting', +}; + /** * @license * Copyright Google LLC All Rights Reserved. @@ -2799,11 +3028,11 @@ function checkOutPullRequestLocally(prNumber, githubToken, opts = {}) { * found in the LICENSE file at https://angular.io/license */ /** Builds the checkout pull request command. */ -function builder$5(yargs) { +function builder$6(yargs) { return addGithubTokenOption(yargs).positional('prNumber', { type: 'number', demandOption: true }); } /** Handles the checkout pull request command. */ -function handler$5({ prNumber, githubToken }) { +function handler$6({ prNumber, githubToken }) { return tslib.__awaiter(this, void 0, void 0, function* () { const prCheckoutOptions = { allowIfMaintainerCannotModify: true, branchName: `pr-${prNumber}` }; yield checkOutPullRequestLocally(prNumber, githubToken, prCheckoutOptions); @@ -2811,8 +3040,8 @@ function handler$5({ prNumber, githubToken }) { } /** yargs command module for checking out a PR */ const CheckoutCommandModule = { - handler: handler$5, - builder: builder$5, + handler: handler$6, + builder: builder$6, command: 'checkout ', describe: 'Checkout a PR from the upstream repo', }; @@ -2985,59 +3214,6 @@ function getThirtyDaysAgoDate() { return date.getTime(); } -/** - * @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 - */ -/** Loads and validates the merge configuration. */ -function loadAndValidateConfig(config, api) { - return tslib.__awaiter(this, void 0, void 0, function () { - var mergeConfig, errors; - return tslib.__generator(this, function (_a) { - switch (_a.label) { - case 0: - if (config.merge === undefined) { - return [2 /*return*/, { errors: ['No merge configuration found. Set the `merge` configuration.'] }]; - } - if (typeof config.merge !== 'function') { - return [2 /*return*/, { errors: ['Expected merge configuration to be defined lazily through a function.'] }]; - } - return [4 /*yield*/, config.merge(api)]; - case 1: - mergeConfig = _a.sent(); - errors = validateMergeConfig(mergeConfig); - if (errors.length) { - return [2 /*return*/, { errors: errors }]; - } - return [2 /*return*/, { config: mergeConfig }]; - } - }); - }); -} -/** Validates the specified configuration. Returns a list of failure messages. */ -function validateMergeConfig(config) { - var errors = []; - if (!config.labels) { - errors.push('No label configuration.'); - } - else if (!Array.isArray(config.labels)) { - errors.push('Label configuration needs to be an array.'); - } - if (!config.claSignedLabel) { - errors.push('No CLA signed label configured.'); - } - if (!config.mergeReadyLabel) { - errors.push('No merge ready label configured.'); - } - if (config.githubApiMerge === undefined) { - errors.push('No explicit choice of merge strategy. Please set `githubApiMerge`.'); - } - return errors; -} - /** * @license * Copyright Google LLC All Rights Reserved. @@ -3121,101 +3297,6 @@ function getTargettedBranchesConfirmationPromptMessage(pullRequest) { return "Pull request #" + pullRequest.prNumber + " will merge into:\n" + targetBranchListAsString + "\nDo you want to proceed merging?"; } -/** - * @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 - */ -/** Checks whether the specified value matches the given pattern. */ -function matchesPattern(value, pattern) { - return typeof pattern === 'string' ? value === pattern : pattern.test(value); -} - -/** - * @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 - */ -/** - * Unique error that can be thrown in the merge configuration if an - * invalid branch is targeted. - */ -var InvalidTargetBranchError = /** @class */ (function () { - function InvalidTargetBranchError(failureMessage) { - this.failureMessage = failureMessage; - } - return InvalidTargetBranchError; -}()); -/** - * Unique error that can be thrown in the merge configuration if an - * invalid label has been applied to a pull request. - */ -var InvalidTargetLabelError = /** @class */ (function () { - function InvalidTargetLabelError(failureMessage) { - this.failureMessage = failureMessage; - } - return InvalidTargetLabelError; -}()); -/** Gets the target label from the specified pull request labels. */ -function getTargetLabelFromPullRequest(config, labels) { - var e_1, _a; - var _loop_1 = function (label) { - var match = config.labels.find(function (_a) { - var pattern = _a.pattern; - return matchesPattern(label, pattern); - }); - if (match !== undefined) { - return { value: match }; - } - }; - try { - for (var labels_1 = tslib.__values(labels), labels_1_1 = labels_1.next(); !labels_1_1.done; labels_1_1 = labels_1.next()) { - var label = labels_1_1.value; - var state_1 = _loop_1(label); - if (typeof state_1 === "object") - return state_1.value; - } - } - catch (e_1_1) { e_1 = { error: e_1_1 }; } - finally { - try { - if (labels_1_1 && !labels_1_1.done && (_a = labels_1.return)) _a.call(labels_1); - } - finally { if (e_1) throw e_1.error; } - } - return null; -} -/** - * Gets the branches from the specified target label. - * - * @throws {InvalidTargetLabelError} Invalid label has been applied to pull request. - * @throws {InvalidTargetBranchError} Invalid Github target branch has been selected. - */ -function getBranchesFromTargetLabel(label, githubTargetBranch) { - return tslib.__awaiter(this, void 0, void 0, function () { - var _a; - return tslib.__generator(this, function (_b) { - switch (_b.label) { - case 0: - if (!(typeof label.branches === 'function')) return [3 /*break*/, 2]; - return [4 /*yield*/, label.branches(githubTargetBranch)]; - case 1: - _a = _b.sent(); - return [3 /*break*/, 4]; - case 2: return [4 /*yield*/, label.branches]; - case 3: - _a = _b.sent(); - _b.label = 4; - case 4: return [2 /*return*/, _a]; - } - }); - }); -} - /** * @license * Copyright Google LLC All Rights Reserved. @@ -4251,7 +4332,8 @@ function buildPrParser(localYargs) { .command('merge ', 'Merge pull requests', buildMergeCommand, handleMergeCommand) .command('discover-new-conflicts ', 'Check if a pending PR causes new conflicts for other pending PRs', buildDiscoverNewConflictsCommand, handleDiscoverNewConflictsCommand) .command('rebase ', 'Rebase a pending PR and push the rebased commits back to Github', buildRebaseCommand, handleRebaseCommand) - .command(CheckoutCommandModule); + .command(CheckoutCommandModule) + .command(CheckTargetBranchesModule); } /** @@ -4718,7 +4800,7 @@ function buildReleaseOutput() { * found in the LICENSE file at https://angular.io/license */ /** Yargs command builder for configuring the `ng-dev release build` command. */ -function builder$6(argv) { +function builder$7(argv) { return argv.option('json', { type: 'boolean', description: 'Whether the built packages should be printed to stdout as JSON.', @@ -4726,7 +4808,7 @@ function builder$6(argv) { }); } /** Yargs command handler for building a release. */ -function handler$6(args) { +function handler$7(args) { return tslib.__awaiter(this, void 0, void 0, function* () { const { npmPackages } = getReleaseConfig(); let builtPackages = yield buildReleaseOutput(); @@ -4761,8 +4843,8 @@ function handler$6(args) { } /** CLI command module for building release output. */ const ReleaseBuildCommandModule = { - builder: builder$6, - handler: handler$6, + builder: builder$7, + handler: handler$7, command: 'build', describe: 'Builds the release output for the current branch.', }; @@ -6217,11 +6299,11 @@ class ReleaseTool { * found in the LICENSE file at https://angular.io/license */ /** Yargs command builder for configuring the `ng-dev release publish` command. */ -function builder$7(argv) { +function builder$8(argv) { return addGithubTokenOption(argv); } /** Yargs command handler for staging a release. */ -function handler$7(args) { +function handler$8(args) { return tslib.__awaiter(this, void 0, void 0, function* () { const config = getConfig(); const releaseConfig = getReleaseConfig(config); @@ -6244,8 +6326,8 @@ function handler$7(args) { } /** CLI command module for publishing a release. */ const ReleasePublishCommandModule = { - builder: builder$7, - handler: handler$7, + builder: builder$8, + handler: handler$8, command: 'publish', describe: 'Publish new releases and configure version branches.', }; @@ -6257,7 +6339,7 @@ const ReleasePublishCommandModule = { * 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 */ -function builder$8(args) { +function builder$9(args) { return args .positional('tagName', { type: 'string', @@ -6271,7 +6353,7 @@ function builder$8(args) { }); } /** Yargs command handler for building a release. */ -function handler$8(args) { +function handler$9(args) { return tslib.__awaiter(this, void 0, void 0, function* () { const { targetVersion: rawVersion, tagName } = args; const { npmPackages, publishRegistry } = getReleaseConfig(); @@ -6303,8 +6385,8 @@ function handler$8(args) { } /** CLI command module for setting an NPM dist tag. */ const ReleaseSetDistTagCommand = { - builder: builder$8, - handler: handler$8, + builder: builder$9, + handler: handler$9, command: 'set-dist-tag ', describe: 'Sets a given NPM dist tag for all release packages.', }; diff --git a/dev-infra/pr/BUILD.bazel b/dev-infra/pr/BUILD.bazel index cf51a59dc9..00a0cee654 100644 --- a/dev-infra/pr/BUILD.bazel +++ b/dev-infra/pr/BUILD.bazel @@ -6,6 +6,7 @@ ts_library( module_name = "@angular/dev-infra-private/pr", visibility = ["//dev-infra:__subpackages__"], deps = [ + "//dev-infra/pr/check-target-branches", "//dev-infra/pr/checkout", "//dev-infra/pr/discover-new-conflicts", "//dev-infra/pr/merge", diff --git a/dev-infra/pr/check-target-branches/BUILD.bazel b/dev-infra/pr/check-target-branches/BUILD.bazel new file mode 100644 index 0000000000..4250f86317 --- /dev/null +++ b/dev-infra/pr/check-target-branches/BUILD.bazel @@ -0,0 +1,13 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_library") + +ts_library( + name = "check-target-branches", + srcs = glob(["*.ts"]), + module_name = "@angular/dev-infra-private/pr/check-target-branches", + visibility = ["//dev-infra:__subpackages__"], + deps = [ + "//dev-infra/pr/merge", + "//dev-infra/utils", + "@npm//@types/yargs", + ], +) diff --git a/dev-infra/pr/check-target-branches/check-target-branches.ts b/dev-infra/pr/check-target-branches/check-target-branches.ts new file mode 100644 index 0000000000..992a4976f2 --- /dev/null +++ b/dev-infra/pr/check-target-branches/check-target-branches.ts @@ -0,0 +1,52 @@ +/** + * @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 {getConfig} from '../../utils/config'; +import {error, info, red} from '../../utils/console'; +import {GitClient} from '../../utils/git/index'; +import {loadAndValidateConfig} from '../merge/config'; +import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest} from '../merge/target-label'; + +export async function checkTargetBranchesForPr(prNumber: number, jsonOutput = false) { + /** The ng-dev configuration. */ + const config = getConfig(); + /** Repo owner and name for the github repository. */ + const {owner, name: repo} = config.github; + /** The git client to get a Github API service instance. */ + const git = new GitClient(undefined, config); + /** The validated merge config. */ + const {config: mergeConfig, errors} = await loadAndValidateConfig(config, git.github); + if (errors !== undefined) { + throw Error(`Invalid configuration found: ${errors}`); + } + /** The current state of the pull request from Github. */ + const prData = (await git.github.pulls.get({owner, repo, pull_number: prNumber})).data; + /** The list of labels on the PR as strings. */ + const labels = prData.labels.map(l => l.name); + /** The branch targetted via the Github UI. */ + const githubTargetBranch = prData.base.ref; + /** The active label which is being used for targetting the PR. */ + const targetLabel = getTargetLabelFromPullRequest(mergeConfig!, labels); + if (targetLabel === null) { + error(red(`No target label was found on pr #${prNumber}`)); + process.exitCode = 1; + return; + } + /** The target branches based on the target label and branch targetted in the Github UI. */ + const targets = await getBranchesFromTargetLabel(targetLabel, githubTargetBranch); + + // When requested, print a json output to stdout, rather than using standard ng-dev logging. + if (jsonOutput) { + process.stdout.write(JSON.stringify(targets)); + return; + } + + info.group(`PR #${prNumber} will merge into:`); + targets.forEach(target => info(`- ${target}`)); + info.groupEnd(); +} diff --git a/dev-infra/pr/check-target-branches/cli.ts b/dev-infra/pr/check-target-branches/cli.ts new file mode 100644 index 0000000000..2ad937c554 --- /dev/null +++ b/dev-infra/pr/check-target-branches/cli.ts @@ -0,0 +1,44 @@ +/** + * @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 {Arguments, Argv, CommandModule} from 'yargs'; + +import {checkTargetBranchesForPr} from './check-target-branches'; + +export interface CheckTargetBranchesOptions { + pr: number; + json: boolean; +} + +/** Builds the command. */ +function builder(yargs: Argv) { + return yargs + .positional('pr', { + description: 'The pull request number', + type: 'number', + demandOption: true, + }) + .option('json', { + type: 'boolean', + default: false, + description: 'Print response as json', + }); +} + +/** Handles the command. */ +async function handler({pr, json}: Arguments) { + await checkTargetBranchesForPr(pr, json); +} + +/** yargs command module describing the command. */ +export const CheckTargetBranchesModule: CommandModule<{}, CheckTargetBranchesOptions> = { + handler, + builder, + command: 'check-target-branches ', + describe: 'Check a PR to determine what branches it is currently targeting', +}; diff --git a/dev-infra/pr/cli.ts b/dev-infra/pr/cli.ts index d05b3069e6..c9e6ccfbca 100644 --- a/dev-infra/pr/cli.ts +++ b/dev-infra/pr/cli.ts @@ -8,6 +8,7 @@ import * as yargs from 'yargs'; +import {CheckTargetBranchesModule} from './check-target-branches/cli'; import {CheckoutCommandModule} from './checkout/cli'; import {buildDiscoverNewConflictsCommand, handleDiscoverNewConflictsCommand} from './discover-new-conflicts/cli'; import {buildMergeCommand, handleMergeCommand} from './merge/cli'; @@ -26,5 +27,6 @@ export function buildPrParser(localYargs: yargs.Argv) { .command( 'rebase ', 'Rebase a pending PR and push the rebased commits back to Github', buildRebaseCommand, handleRebaseCommand) - .command(CheckoutCommandModule); + .command(CheckoutCommandModule) + .command(CheckTargetBranchesModule); }