diff --git a/.ng-dev/format.ts b/.ng-dev/format.ts index ed625e3e32..b75dd04979 100644 --- a/.ng-dev/format.ts +++ b/.ng-dev/format.ts @@ -16,6 +16,8 @@ export const format: FormatConfig = { '!.yarn/**', // Do not format d.ts files as they are generated '!**/*.d.ts', + // Do not format generated ng-dev script + '!dev-infra/ng-dev.js', ] }, 'buildifier': true diff --git a/dev-infra/BUILD.bazel b/dev-infra/BUILD.bazel index 5492b72fdd..e3cdd2f5a2 100644 --- a/dev-infra/BUILD.bazel +++ b/dev-infra/BUILD.bazel @@ -1,5 +1,6 @@ -load("@build_bazel_rules_nodejs//:index.bzl", "pkg_npm") +load("@build_bazel_rules_nodejs//:index.bzl", "generated_file_test", "pkg_npm") load("@npm//@bazel/typescript:index.bzl", "ts_library") +load("@npm//@bazel/rollup:index.bzl", "rollup_bundle") ts_library( name = "cli", @@ -23,6 +24,23 @@ ts_library( ], ) +rollup_bundle( + name = "cli_rollup", + entry_point = ":cli.ts", + format = "cjs", + silent = True, + sourcemap = "false", + deps = [ + ":cli", + ], +) + +generated_file_test( + name = "local_ng_dev", + src = "ng-dev.js", + generated = "cli_rollup", +) + genrule( name = "package-json", srcs = [ diff --git a/dev-infra/caretaker/check/check.ts b/dev-infra/caretaker/check/check.ts index ebd51125f8..fdbdbe31c4 100644 --- a/dev-infra/caretaker/check/check.ts +++ b/dev-infra/caretaker/check/check.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {GitClient} from '../../utils/git'; +import {GitClient} from '../../utils/git/index'; import {getCaretakerConfig} from '../config'; import {printCiStatus} from './ci'; diff --git a/dev-infra/caretaker/check/ci.ts b/dev-infra/caretaker/check/ci.ts index ff32fe052c..bdadcb9d04 100644 --- a/dev-infra/caretaker/check/ci.ts +++ b/dev-infra/caretaker/check/ci.ts @@ -10,7 +10,7 @@ import fetch from 'node-fetch'; import {fetchActiveReleaseTrains} from '../../release/versioning/index'; import {bold, debug, info} from '../../utils/console'; -import {GitClient} from '../../utils/git'; +import {GitClient} from '../../utils/git/index'; /** The results of checking the status of CI. */ diff --git a/dev-infra/caretaker/check/g3.ts b/dev-infra/caretaker/check/g3.ts index 1a5d8a48e6..6674322360 100644 --- a/dev-infra/caretaker/check/g3.ts +++ b/dev-infra/caretaker/check/g3.ts @@ -13,7 +13,7 @@ import {parse as parseYaml} from 'yaml'; import {getRepoBaseDir} from '../../utils/config'; import {bold, debug, info} from '../../utils/console'; -import {GitClient} from '../../utils/git'; +import {GitClient} from '../../utils/git/index'; /** Compare the upstream master to the upstream g3 branch, if it exists. */ export async function printG3Comparison(git: GitClient) { @@ -120,6 +120,8 @@ export async function printG3Comparison(git: GitClient) { /** Determine whether the file name passes both include and exclude checks. */ function checkMatchAgainstIncludeAndExclude( file: string, includes: string[], excludes: string[]) { - return multimatch(file, includes).length >= 1 && multimatch(file, excludes).length === 0; + return ( + multimatch.call(undefined, file, includes).length >= 1 && + multimatch.call(undefined, file, excludes).length === 0); } } diff --git a/dev-infra/caretaker/check/github.ts b/dev-infra/caretaker/check/github.ts index 664e8b539f..6b02a3dbdd 100644 --- a/dev-infra/caretaker/check/github.ts +++ b/dev-infra/caretaker/check/github.ts @@ -9,7 +9,7 @@ import {alias, onUnion, params, types} from 'typed-graphqlify'; import {bold, debug, info} from '../../utils/console'; -import {GitClient} from '../../utils/git'; +import {GitClient} from '../../utils/git/index'; import {CaretakerConfig} from '../config'; /** diff --git a/dev-infra/cli.ts b/dev-infra/cli.ts index c83db4117c..d61055be0e 100644 --- a/dev-infra/cli.ts +++ b/dev-infra/cli.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node /** * @license * Copyright Google LLC All Rights Reserved. @@ -7,15 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ import * as yargs from 'yargs'; -import {tsCircularDependenciesBuilder} from './ts-circular-dependencies/index'; -import {buildPullapproveParser} from './pullapprove/cli'; + +import {buildCaretakerParser} from './caretaker/cli'; import {buildCommitMessageParser} from './commit-message/cli'; import {buildFormatParser} from './format/cli'; -import {buildReleaseParser} from './release/cli'; -import {buildPrParser} from './pr/cli'; -import {captureLogOutputForCommand} from './utils/console'; -import {buildCaretakerParser} from './caretaker/cli'; import {buildNgbotParser} from './ngbot/cli'; +import {buildPrParser} from './pr/cli'; +import {buildPullapproveParser} from './pullapprove/cli'; +import {buildReleaseParser} from './release/cli'; +import {tsCircularDependenciesBuilder} from './ts-circular-dependencies/index'; +import {captureLogOutputForCommand} from './utils/console'; yargs.scriptName('ng-dev') .middleware(captureLogOutputForCommand) diff --git a/dev-infra/commit-message-filter.js b/dev-infra/commit-message-filter.js new file mode 120000 index 0000000000..abb734a839 --- /dev/null +++ b/dev-infra/commit-message-filter.js @@ -0,0 +1 @@ +pr/merge/strategies/commit-message-filter.js \ No newline at end of file diff --git a/dev-infra/commit-message/cli.ts b/dev-infra/commit-message/cli.ts index 9230b03d0c..f952ab569c 100644 --- a/dev-infra/commit-message/cli.ts +++ b/dev-infra/commit-message/cli.ts @@ -21,7 +21,3 @@ export function buildCommitMessageParser(localYargs: yargs.Argv) { .command(ValidateFileModule) .command(ValidateRangeModule); } - -if (require.main == module) { - buildCommitMessageParser(yargs).parse(); -} diff --git a/dev-infra/format/cli.ts b/dev-infra/format/cli.ts index 77dc650369..8b3c55297e 100644 --- a/dev-infra/format/cli.ts +++ b/dev-infra/format/cli.ts @@ -48,7 +48,3 @@ export function buildFormatParser(localYargs: yargs.Argv) { executionCmd(files!); }); } - -if (require.main === module) { - buildFormatParser(yargs).parse(); -} diff --git a/dev-infra/format/run-commands-parallel.ts b/dev-infra/format/run-commands-parallel.ts index f60980d640..51dcc34aeb 100644 --- a/dev-infra/format/run-commands-parallel.ts +++ b/dev-infra/format/run-commands-parallel.ts @@ -13,7 +13,7 @@ import {exec} from 'shelljs'; import {info} from '../utils/console'; -import {Formatter, FormatterAction, getActiveFormatters} from './formatters'; +import {Formatter, FormatterAction, getActiveFormatters} from './formatters/index'; const AVAILABLE_THREADS = Math.max(cpus().length - 1, 1); @@ -36,9 +36,9 @@ export function runFormatterInParallel(allFiles: string[], action: FormatterActi const pendingCommands: {formatter: Formatter, file: string}[] = []; for (const formatter of formatters) { - pendingCommands.push(...multimatch(allFiles, formatter.getFileMatcher(), { - dot: true - }).map(file => ({formatter, file}))); + pendingCommands.push( + ...multimatch.call(undefined, allFiles, formatter.getFileMatcher(), {dot: true}) + .map(file => ({formatter, file}))); } // If no commands are generated, resolve the promise as `false` as no files diff --git a/dev-infra/ng-dev b/dev-infra/ng-dev deleted file mode 100755 index b29c22cbb4..0000000000 --- a/dev-infra/ng-dev +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env ts-node -/** - * @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 the ng-dev cli, automatically executing it. -require('./cli.ts'); diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js new file mode 100755 index 0000000000..7b26460334 --- /dev/null +++ b/dev-infra/ng-dev.js @@ -0,0 +1,6801 @@ +'use strict'; + +function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } + +var yargs = require('yargs'); +var tslib = require('tslib'); +var chalk = _interopDefault(require('chalk')); +var fs = require('fs'); +var inquirer = require('inquirer'); +var inquirerAutocomplete = require('inquirer-autocomplete-prompt'); +var path = require('path'); +var shelljs = require('shelljs'); +var url = require('url'); +var child_process = require('child_process'); +var graphql = require('@octokit/graphql'); +var Octokit = require('@octokit/rest'); +var typedGraphqlify = require('typed-graphqlify'); +var fetch = _interopDefault(require('node-fetch')); +var semver = require('semver'); +var multimatch = require('multimatch'); +var yaml = require('yaml'); +var cliProgress = require('cli-progress'); +var os = require('os'); +var minimatch = require('minimatch'); +var ora = require('ora'); +var glob = require('glob'); +var ts = require('typescript'); + +/** + * @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 + */ +/** + * Runs an given command as child process. By default, child process + * output will not be printed. + */ +function exec(cmd, opts) { + return shelljs.exec(cmd, tslib.__assign(tslib.__assign({ silent: true }, opts), { async: false })); +} + +/** + * @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 + */ +/** Whether ts-node has been installed and is available to ng-dev. */ +function isTsNodeAvailable() { + try { + require.resolve('ts-node'); + return true; + } + catch (_a) { + return false; + } +} + +/** + * @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 + */ +/** + * The filename expected for creating the ng-dev config, without the file + * extension to allow either a typescript or javascript file to be used. + */ +var CONFIG_FILE_PATH = '.ng-dev/config'; +/** The configuration for ng-dev. */ +var cachedConfig = null; +/** + * The filename expected for local user config, without the file extension to allow a typescript, + * javascript or json file to be used. + */ +var USER_CONFIG_FILE_PATH = '.ng-dev.user'; +/** The local user configuration for ng-dev. */ +var userConfig = null; +/** + * Get the configuration from the file system, returning the already loaded + * copy if it is defined. + */ +function getConfig() { + // If the global config is not defined, load it from the file system. + if (cachedConfig === null) { + // The full path to the configuration file. + var configPath = path.join(getRepoBaseDir(), CONFIG_FILE_PATH); + // Read the configuration and validate it before caching it for the future. + cachedConfig = validateCommonConfig(readConfigFile(configPath)); + } + // Return a clone of the cached global config to ensure that a new instance of the config + // is returned each time, preventing unexpected effects of modifications to the config object. + return tslib.__assign({}, cachedConfig); +} +/** Validate the common configuration has been met for the ng-dev command. */ +function validateCommonConfig(config) { + var errors = []; + // Validate the github configuration. + if (config.github === undefined) { + errors.push("Github repository not configured. Set the \"github\" option."); + } + else { + if (config.github.name === undefined) { + errors.push("\"github.name\" is not defined"); + } + if (config.github.owner === undefined) { + errors.push("\"github.owner\" is not defined"); + } + } + assertNoErrors(errors); + return config; +} +/** + * Resolves and reads the specified configuration file, optionally returning an empty object if the + * configuration file cannot be read. + */ +function readConfigFile(configPath, returnEmptyObjectOnError) { + if (returnEmptyObjectOnError === void 0) { returnEmptyObjectOnError = false; } + // If the the `.ts` extension has not been set up already, and a TypeScript based + // version of the given configuration seems to exist, set up `ts-node` if available. + if (require.extensions['.ts'] === undefined && fs.existsSync(configPath + ".ts") && + isTsNodeAvailable()) { + // Ensure the module target is set to `commonjs`. This is necessary because the + // dev-infra tool runs in NodeJS which does not support ES modules by default. + // Additionally, set the `dir` option to the directory that contains the configuration + // file. This allows for custom compiler options (such as `--strict`). + require('ts-node').register({ dir: path.dirname(configPath), transpileOnly: true, compilerOptions: { module: 'commonjs' } }); + } + try { + return require(configPath); + } + catch (e) { + if (returnEmptyObjectOnError) { + debug("Could not read configuration file at " + configPath + ", returning empty object instead."); + debug(e); + return {}; + } + error("Could not read configuration file at " + configPath + "."); + error(e); + process.exit(1); + } +} +/** + * Asserts the provided array of error messages is empty. If any errors are in the array, + * logs the errors and exit the process as a failure. + */ +function assertNoErrors(errors) { + var e_1, _a; + if (errors.length == 0) { + return; + } + error("Errors discovered while loading configuration file:"); + try { + for (var errors_1 = tslib.__values(errors), errors_1_1 = errors_1.next(); !errors_1_1.done; errors_1_1 = errors_1.next()) { + var err = errors_1_1.value; + error(" - " + err); + } + } + catch (e_1_1) { e_1 = { error: e_1_1 }; } + finally { + try { + if (errors_1_1 && !errors_1_1.done && (_a = errors_1.return)) _a.call(errors_1); + } + finally { if (e_1) throw e_1.error; } + } + process.exit(1); +} +/** Gets the path of the directory for the repository base. */ +function getRepoBaseDir() { + var baseRepoDir = exec("git rev-parse --show-toplevel"); + if (baseRepoDir.code) { + throw Error("Unable to find the path to the base directory of the repository.\n" + + "Was the command run from inside of the repo?\n\n" + + ("ERROR:\n " + baseRepoDir.stderr)); + } + return baseRepoDir.trim(); +} +/** + * Get the local user configuration from the file system, returning the already loaded copy if it is + * defined. + * + * @returns The user configuration object, or an empty object if no user configuration file is + * present. The object is an untyped object as there are no required user configurations. + */ +function getUserConfig() { + // If the global config is not defined, load it from the file system. + if (userConfig === null) { + // The full path to the configuration file. + var configPath = path.join(getRepoBaseDir(), USER_CONFIG_FILE_PATH); + // Set the global config object. + userConfig = readConfigFile(configPath, true); + } + // Return a clone of the user config to ensure that a new instance of the config is returned + // each time, preventing unexpected effects of modifications to the config object. + return tslib.__assign({}, userConfig); +} + +/** + * @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 + */ +/** Reexport of chalk colors for convenient access. */ +var red = chalk.red; +var green = chalk.green; +var yellow = chalk.yellow; +var bold = chalk.bold; +var blue = chalk.blue; +/** Prompts the user with a confirmation question and a specified message. */ +function promptConfirm(message, defaultValue) { + if (defaultValue === void 0) { defaultValue = false; } + return tslib.__awaiter(this, void 0, void 0, function () { + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, inquirer.prompt({ + type: 'confirm', + name: 'result', + message: message, + default: defaultValue, + })]; + case 1: return [2 /*return*/, (_a.sent()) + .result]; + } + }); + }); +} +function promptAutocomplete(message, choices, noChoiceText) { + return tslib.__awaiter(this, void 0, void 0, function () { + var prompt, result; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + prompt = inquirer.createPromptModule({}).registerPrompt('autocomplete', inquirerAutocomplete); + if (noChoiceText) { + choices = tslib.__spread([noChoiceText], choices); + } + return [4 /*yield*/, prompt({ + type: 'autocomplete', + name: 'result', + message: message, + source: function (_, input) { + if (!input) { + return Promise.resolve(choices); + } + return Promise.resolve(choices.filter(function (choice) { + if (typeof choice === 'string') { + return choice.includes(input); + } + return choice.name.includes(input); + })); + } + })]; + case 1: + result = (_a.sent()).result; + if (result === noChoiceText) { + return [2 /*return*/, false]; + } + return [2 /*return*/, result]; + } + }); + }); +} +/** Prompts the user for one line of input. */ +function promptInput(message) { + return tslib.__awaiter(this, void 0, void 0, function () { + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, inquirer.prompt({ type: 'input', name: 'result', message: message })]; + case 1: return [2 /*return*/, (_a.sent()).result]; + } + }); + }); +} +/** + * Supported levels for logging functions. + * + * Levels are mapped to numbers to represent a hierarchy of logging levels. + */ +var LOG_LEVELS; +(function (LOG_LEVELS) { + LOG_LEVELS[LOG_LEVELS["SILENT"] = 0] = "SILENT"; + LOG_LEVELS[LOG_LEVELS["ERROR"] = 1] = "ERROR"; + LOG_LEVELS[LOG_LEVELS["WARN"] = 2] = "WARN"; + LOG_LEVELS[LOG_LEVELS["LOG"] = 3] = "LOG"; + LOG_LEVELS[LOG_LEVELS["INFO"] = 4] = "INFO"; + LOG_LEVELS[LOG_LEVELS["DEBUG"] = 5] = "DEBUG"; +})(LOG_LEVELS || (LOG_LEVELS = {})); +/** Default log level for the tool. */ +var DEFAULT_LOG_LEVEL = LOG_LEVELS.INFO; +/** Write to the console for at INFO logging level */ +var info = buildLogLevelFunction(function () { return console.info; }, LOG_LEVELS.INFO); +/** Write to the console for at ERROR logging level */ +var error = buildLogLevelFunction(function () { return console.error; }, LOG_LEVELS.ERROR); +/** Write to the console for at DEBUG logging level */ +var debug = buildLogLevelFunction(function () { return console.debug; }, LOG_LEVELS.DEBUG); +/** Write to the console for at LOG logging level */ +// tslint:disable-next-line: no-console +var log = buildLogLevelFunction(function () { return console.log; }, LOG_LEVELS.LOG); +/** Write to the console for at WARN logging level */ +var warn = buildLogLevelFunction(function () { return console.warn; }, LOG_LEVELS.WARN); +/** Build an instance of a logging function for the provided level. */ +function buildLogLevelFunction(loadCommand, level) { + /** Write to stdout for the LOG_LEVEL. */ + var loggingFunction = function () { + var text = []; + for (var _i = 0; _i < arguments.length; _i++) { + text[_i] = arguments[_i]; + } + runConsoleCommand.apply(void 0, tslib.__spread([loadCommand, level], text)); + }; + /** Start a group at the LOG_LEVEL, optionally starting it as collapsed. */ + loggingFunction.group = function (text, collapsed) { + if (collapsed === void 0) { collapsed = false; } + var command = collapsed ? console.groupCollapsed : console.group; + runConsoleCommand(function () { return command; }, level, text); + }; + /** End the group at the LOG_LEVEL. */ + loggingFunction.groupEnd = function () { + runConsoleCommand(function () { return console.groupEnd; }, level); + }; + return loggingFunction; +} +/** + * Run the console command provided, if the environments logging level greater than the + * provided logging level. + * + * The loadCommand takes in a function which is called to retrieve the console.* function + * to allow for jasmine spies to still work in testing. Without this method of retrieval + * the console.* function, the function is saved into the closure of the created logging + * function before jasmine can spy. + */ +function runConsoleCommand(loadCommand, logLevel) { + var text = []; + for (var _i = 2; _i < arguments.length; _i++) { + text[_i - 2] = arguments[_i]; + } + if (getLogLevel() >= logLevel) { + loadCommand().apply(void 0, tslib.__spread(text)); + } + printToLogFile.apply(void 0, tslib.__spread([logLevel], text)); +} +/** + * Retrieve the log level from environment variables, if the value found + * based on the LOG_LEVEL environment variable is undefined, return the default + * logging level. + */ +function getLogLevel() { + var logLevelEnvValue = (process.env["LOG_LEVEL"] || '').toUpperCase(); + var logLevel = LOG_LEVELS[logLevelEnvValue]; + if (logLevel === undefined) { + return DEFAULT_LOG_LEVEL; + } + return logLevel; +} +/** All text to write to the log file. */ +var LOGGED_TEXT = ''; +/** Whether file logging as been enabled. */ +var FILE_LOGGING_ENABLED = false; +/** + * The number of columns used in the prepended log level information on each line of the logging + * output file. + */ +var LOG_LEVEL_COLUMNS = 7; +/** + * Enable writing the logged outputs to the log file on process exit, sets initial lines from the + * command execution, containing information about the timing and command parameters. + * + * This is expected to be called only once during a command run, and should be called by the + * middleware of yargs to enable the file logging before the rest of the command parsing and + * response is executed. + */ +function captureLogOutputForCommand(argv) { + if (FILE_LOGGING_ENABLED) { + throw Error('`captureLogOutputForCommand` cannot be called multiple times'); + } + /** The date time used for timestamping when the command was invoked. */ + var now = new Date(); + /** Header line to separate command runs in log files. */ + var headerLine = Array(100).fill('#').join(''); + LOGGED_TEXT += headerLine + "\nCommand: " + argv.$0 + " " + argv._.join(' ') + "\nRan at: " + now + "\n"; + // On process exit, write the logged output to the appropriate log files + process.on('exit', function (code) { + LOGGED_TEXT += "Command ran in " + (new Date().getTime() - now.getTime()) + "ms"; + /** Path to the log file location. */ + var logFilePath = path.join(getRepoBaseDir(), '.ng-dev.log'); + // Strip ANSI escape codes from log outputs. + LOGGED_TEXT = LOGGED_TEXT.replace(/\x1B\[([0-9]{1,3}(;[0-9]{1,2})?)?[mGK]/g, ''); + fs.writeFileSync(logFilePath, LOGGED_TEXT); + // For failure codes greater than 1, the new logged lines should be written to a specific log + // file for the command run failure. + if (code > 1) { + fs.writeFileSync(path.join(getRepoBaseDir(), ".ng-dev.err-" + now.getTime() + ".log"), LOGGED_TEXT); + } + }); + // Mark file logging as enabled to prevent the function from executing multiple times. + FILE_LOGGING_ENABLED = true; +} +/** Write the provided text to the log file, prepending each line with the log level. */ +function printToLogFile(logLevel) { + var text = []; + for (var _i = 1; _i < arguments.length; _i++) { + text[_i - 1] = arguments[_i]; + } + var logLevelText = (LOG_LEVELS[logLevel] + ":").padEnd(LOG_LEVEL_COLUMNS); + LOGGED_TEXT += text.join(' ').split('\n').map(function (l) { return logLevelText + " " + l + "\n"; }).join(''); +} + +/** + * @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 + */ +/** URL to the Github page where personal access tokens can be managed. */ +var GITHUB_TOKEN_SETTINGS_URL = 'https://github.com/settings/tokens'; +/** URL to the Github page where personal access tokens can be generated. */ +var GITHUB_TOKEN_GENERATE_URL = 'https://github.com/settings/tokens/new'; +/** Adds the provided token to the given Github HTTPs remote url. */ +function addTokenToGitHttpsUrl(githubHttpsUrl, token) { + var url$1 = new url.URL(githubHttpsUrl); + url$1.username = token; + return url$1.href; +} +/** Gets the repository Git URL for the given github config. */ +function getRepositoryGitUrl(config, githubToken) { + if (config.useSsh) { + return "git@github.com:" + config.owner + "/" + config.name + ".git"; + } + var baseHttpUrl = "https://github.com/" + config.owner + "/" + config.name + ".git"; + if (githubToken !== undefined) { + return addTokenToGitHttpsUrl(baseHttpUrl, githubToken); + } + return baseHttpUrl; +} +/** Gets a Github URL that refers to a list of recent commits within a specified branch. */ +function getListCommitsInBranchUrl(_a, branchName) { + var remoteParams = _a.remoteParams; + return "https://github.com/" + remoteParams.owner + "/" + remoteParams.repo + "/commits/" + branchName; +} + +/** + * @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 + */ +/** Sets up the `github-token` command option for the given Yargs instance. */ +function addGithubTokenOption(yargs) { + return yargs + // 'github-token' is casted to 'githubToken' to properly set up typings to reflect the key in + // the Argv object being camelCase rather than kebob case due to the `camel-case-expansion` + // config: https://github.com/yargs/yargs-parser#camel-case-expansion + .option('github-token', { + type: 'string', + description: 'Github token. If not set, token is retrieved from the environment variables.', + coerce: function (token) { + var githubToken = token || process.env.GITHUB_TOKEN || process.env.TOKEN; + if (!githubToken) { + error(red('No Github token set. Please set the `GITHUB_TOKEN` environment variable.')); + error(red('Alternatively, pass the `--github-token` command line flag.')); + error(yellow("You can generate a token here: " + GITHUB_TOKEN_GENERATE_URL)); + process.exit(1); + } + return githubToken; + }, + }) + .default('github-token', '', ''); +} + +/** + * @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 + */ +/** Error for failed Github API requests. */ +var GithubApiRequestError = /** @class */ (function (_super) { + tslib.__extends(GithubApiRequestError, _super); + function GithubApiRequestError(status, message) { + var _this = _super.call(this, message) || this; + _this.status = status; + return _this; + } + return GithubApiRequestError; +}(Error)); +/** + * A Github client for interacting with the Github APIs. + * + * Additionally, provides convenience methods for actions which require multiple requests, or + * would provide value from memoized style responses. + **/ +var GithubClient = /** @class */ (function (_super) { + tslib.__extends(GithubClient, _super); + function GithubClient(token) { + var _this = + // Pass in authentication token to base Octokit class. + _super.call(this, { auth: token }) || this; + /** The current user based on checking against the Github API. */ + _this._currentUser = null; + _this.hook.error('request', function (error) { + // Wrap API errors in a known error class. This allows us to + // expect Github API errors better and in a non-ambiguous way. + throw new GithubApiRequestError(error.status, error.message); + }); + // Create authenticated graphql client. + _this.graphql = new GithubGraphqlClient(token); + return _this; + } + /** Retrieve the login of the current user from Github. */ + GithubClient.prototype.getCurrentUser = function () { + return tslib.__awaiter(this, void 0, void 0, function () { + var result; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + // If the current user has already been retrieved return the current user value again. + if (this._currentUser !== null) { + return [2 /*return*/, this._currentUser]; + } + return [4 /*yield*/, this.graphql.query({ + viewer: { + login: typedGraphqlify.types.string, + } + })]; + case 1: + result = _a.sent(); + return [2 /*return*/, this._currentUser = result.viewer.login]; + } + }); + }); + }; + return GithubClient; +}(Octokit)); +/** A client for interacting with Github's GraphQL API. */ +var GithubGraphqlClient = /** @class */ (function () { + function GithubGraphqlClient(token) { + /** The Github GraphQL (v4) API. */ + this.graqhql = graphql.graphql; + // Set the default headers to include authorization with the provided token for all + // graphQL calls. + if (token) { + this.graqhql = this.graqhql.defaults({ headers: { authorization: "token " + token } }); + } + } + /** Perform a query using Github's GraphQL API. */ + GithubGraphqlClient.prototype.query = function (queryObject, params) { + if (params === void 0) { params = {}; } + return tslib.__awaiter(this, void 0, void 0, function () { + var queryString; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + queryString = typedGraphqlify.query(queryObject); + return [4 /*yield*/, this.graqhql(queryString, params)]; + case 1: return [2 /*return*/, (_a.sent())]; + } + }); + }); + }; + return GithubGraphqlClient; +}()); + +/** + * @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 + */ +/** Error for failed Git commands. */ +var GitCommandError = /** @class */ (function (_super) { + tslib.__extends(GitCommandError, _super); + function GitCommandError(client, args) { + var _this = + // Errors are not guaranteed to be caught. To ensure that we don't + // accidentally leak the Github token that might be used in a command, + // we sanitize the command that will be part of the error message. + _super.call(this, "Command failed: git " + client.omitGithubTokenFromMessage(args.join(' '))) || this; + _this.args = args; + return _this; + } + return GitCommandError; +}(Error)); +/** + * Common client for performing Git interactions with a given remote. + * + * Takes in two optional arguments: + * `githubToken`: the token used for authentication in Github interactions, by default empty + * allowing readonly actions. + * `config`: The dev-infra configuration containing information about the remote. By default + * the dev-infra configuration is loaded with its Github configuration. + **/ +var GitClient = /** @class */ (function () { + function GitClient(githubToken, _config, _projectRoot) { + if (_config === void 0) { _config = getConfig(); } + if (_projectRoot === void 0) { _projectRoot = getRepoBaseDir(); } + this.githubToken = githubToken; + this._config = _config; + this._projectRoot = _projectRoot; + /** Short-hand for accessing the default remote configuration. */ + this.remoteConfig = this._config.github; + /** Octokit request parameters object for targeting the configured remote. */ + this.remoteParams = { owner: this.remoteConfig.owner, repo: this.remoteConfig.name }; + /** Git URL that resolves to the configured repository. */ + this.repoGitUrl = getRepositoryGitUrl(this.remoteConfig, this.githubToken); + /** Instance of the authenticated Github octokit API. */ + this.github = new GithubClient(this.githubToken); + /** The OAuth scopes available for the provided Github token. */ + this._cachedOauthScopes = null; + /** + * Regular expression that matches the provided Github token. Used for + * sanitizing the token from Git child process output. + */ + this._githubTokenRegex = null; + // If a token has been specified (and is not empty), pass it to the Octokit API and + // also create a regular expression that can be used for sanitizing Git command output + // so that it does not print the token accidentally. + if (githubToken != null) { + this._githubTokenRegex = new RegExp(githubToken, 'g'); + } + } + /** Executes the given git command. Throws if the command fails. */ + GitClient.prototype.run = function (args, options) { + var result = this.runGraceful(args, options); + if (result.status !== 0) { + throw new GitCommandError(this, args); + } + // Omit `status` from the type so that it's obvious that the status is never + // non-zero as explained in the method description. + return result; + }; + /** + * Spawns a given Git command process. Does not throw if the command fails. Additionally, + * if there is any stderr output, the output will be printed. This makes it easier to + * info failed commands. + */ + GitClient.prototype.runGraceful = function (args, options) { + if (options === void 0) { options = {}; } + // To improve the debugging experience in case something fails, we print all executed Git + // commands unless the `stdio` is explicitly set to `ignore` (which is equivalent to silent). + // Note that we do not want to print the token if it is contained in the command. It's common + // to share errors with others if the tool failed, and we do not want to leak tokens. + // TODO: Add support for configuring this on a per-client basis. Some tools do not want + // to print the Git command messages to the console at all (e.g. to maintain clean output). + var printFn = options.stdio !== 'ignore' ? info : debug; + printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' '))); + var result = child_process.spawnSync('git', args, tslib.__assign(tslib.__assign({ cwd: this._projectRoot, stdio: 'pipe' }, options), { + // Encoding is always `utf8` and not overridable. This ensures that this method + // always returns `string` as output instead of buffers. + encoding: 'utf8' })); + if (result.stderr !== null) { + // Git sometimes prints the command if it failed. This means that it could + // potentially leak the Github token used for accessing the remote. To avoid + // printing a token, we sanitize the string before printing the stderr output. + process.stderr.write(this.omitGithubTokenFromMessage(result.stderr)); + } + return result; + }; + /** Whether the given branch contains the specified SHA. */ + GitClient.prototype.hasCommit = function (branchName, sha) { + return this.run(['branch', branchName, '--contains', sha]).stdout !== ''; + }; + /** Gets the currently checked out branch or revision. */ + GitClient.prototype.getCurrentBranchOrRevision = function () { + var branchName = this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim(); + // If no branch name could be resolved. i.e. `HEAD` has been returned, then Git + // is currently in a detached state. In those cases, we just want to return the + // currently checked out revision/SHA. + if (branchName === 'HEAD') { + return this.run(['rev-parse', 'HEAD']).stdout.trim(); + } + return branchName; + }; + /** Gets whether the current Git repository has uncommitted changes. */ + GitClient.prototype.hasUncommittedChanges = function () { + return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0; + }; + /** Whether the repo has any local changes. */ + GitClient.prototype.hasLocalChanges = function () { + return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0; + }; + /** Sanitizes a given message by omitting the provided Github token if present. */ + GitClient.prototype.omitGithubTokenFromMessage = function (value) { + // If no token has been defined (i.e. no token regex), we just return the + // value as is. There is no secret value that needs to be omitted. + if (this._githubTokenRegex === null) { + return value; + } + return value.replace(this._githubTokenRegex, ''); + }; + /** + * Checks out a requested branch or revision, optionally cleaning the state of the repository + * before attempting the checking. Returns a boolean indicating whether the branch or revision + * was cleanly checked out. + */ + GitClient.prototype.checkout = function (branchOrRevision, cleanState) { + if (cleanState) { + // Abort any outstanding ams. + this.runGraceful(['am', '--abort'], { stdio: 'ignore' }); + // Abort any outstanding cherry-picks. + this.runGraceful(['cherry-pick', '--abort'], { stdio: 'ignore' }); + // Abort any outstanding rebases. + this.runGraceful(['rebase', '--abort'], { stdio: 'ignore' }); + // Clear any changes in the current repo. + this.runGraceful(['reset', '--hard'], { stdio: 'ignore' }); + } + return this.runGraceful(['checkout', branchOrRevision], { stdio: 'ignore' }).status === 0; + }; + /** + * Assert the GitClient instance is using a token with permissions for the all of the + * provided OAuth scopes. + */ + GitClient.prototype.hasOauthScopes = function (testFn) { + return tslib.__awaiter(this, void 0, void 0, function () { + var scopes, missingScopes, error; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, this.getAuthScopesForToken()]; + case 1: + scopes = _a.sent(); + missingScopes = []; + // Test Github OAuth scopes and collect missing ones. + testFn(scopes, missingScopes); + // If no missing scopes are found, return true to indicate all OAuth Scopes are available. + if (missingScopes.length === 0) { + return [2 /*return*/, true]; + } + error = "The provided does not have required permissions due to missing scope(s): " + + (yellow(missingScopes.join(', ')) + "\n\n") + + "Update the token in use at:\n" + + (" " + GITHUB_TOKEN_SETTINGS_URL + "\n\n") + + ("Alternatively, a new token can be created at: " + GITHUB_TOKEN_GENERATE_URL + "\n"); + return [2 /*return*/, { error: error }]; + } + }); + }); + }; + /** + * Retrieve the OAuth scopes for the loaded Github token. + **/ + GitClient.prototype.getAuthScopesForToken = function () { + // If the OAuth scopes have already been loaded, return the Promise containing them. + if (this._cachedOauthScopes !== null) { + return this._cachedOauthScopes; + } + // OAuth scopes are loaded via the /rate_limit endpoint to prevent + // usage of a request against that rate_limit for this lookup. + return this._cachedOauthScopes = this.github.rateLimit.get().then(function (_response) { + var response = _response; + var scopes = response.headers['x-oauth-scopes'] || ''; + return scopes.split(',').map(function (scope) { return scope.trim(); }); + }); + }; + return GitClient; +}()); + +/** + * @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 + */ +/** Retrieve and validate the config as `CaretakerConfig`. */ +function getCaretakerConfig() { + // List of errors encountered validating the config. + const errors = []; + // The non-validated config object. + const config = getConfig(); + assertNoErrors(errors); + return config; +} + +/** + * @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 + */ +/** Class describing a release-train. */ +class ReleaseTrain { + constructor( + /** Name of the branch for this release-train. */ + branchName, + /** Most recent version for this release train. */ + version) { + this.branchName = branchName; + this.version = version; + /** Whether the release train is currently targeting a major. */ + this.isMajor = this.version.minor === 0 && this.version.patch === 0; + } +} + +/** + * @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 + */ +/** Regular expression that matches version-branches. */ +const versionBranchNameRegex = /^(\d+)\.(\d+)\.x$/; +/** Gets the version of a given branch by reading the `package.json` upstream. */ +function getVersionOfBranch(repo, branchName) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { data } = yield repo.api.repos.getContents({ owner: repo.owner, repo: repo.name, path: '/package.json', ref: branchName }); + const { version } = JSON.parse(Buffer.from(data.content, 'base64').toString()); + const parsedVersion = semver.parse(version); + if (parsedVersion === null) { + throw Error(`Invalid version detected in following branch: ${branchName}.`); + } + return parsedVersion; + }); +} +/** Whether the given branch corresponds to a version branch. */ +function isVersionBranch(branchName) { + return versionBranchNameRegex.test(branchName); +} +/** + * Converts a given version-branch into a SemVer version that can be used with SemVer + * utilities. e.g. to determine semantic order, extract major digit, compare. + * + * For example `10.0.x` will become `10.0.0` in SemVer. The patch digit is not + * relevant but needed for parsing. SemVer does not allow `x` as patch digit. + */ +function getVersionForVersionBranch(branchName) { + return semver.parse(branchName.replace(versionBranchNameRegex, '$1.$2.0')); +} +/** + * Gets the version branches for the specified major versions in descending + * order. i.e. latest version branches first. + */ +function getBranchesForMajorVersions(repo, majorVersions) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { data: branchData } = yield repo.api.repos.listBranches({ owner: repo.owner, repo: repo.name, protected: true }); + const branches = []; + for (const { name } of branchData) { + if (!isVersionBranch(name)) { + continue; + } + // Convert the version-branch into a SemVer version that can be used with the + // SemVer utilities. e.g. to determine semantic order, compare versions. + const parsed = getVersionForVersionBranch(name); + // Collect all version-branches that match the specified major versions. + if (parsed !== null && majorVersions.includes(parsed.major)) { + branches.push({ name, parsed }); + } + } + // Sort captured version-branches in descending order. + return branches.sort((a, b) => semver.rcompare(a.parsed, b.parsed)); + }); +} + +/** + * @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 + */ +/** Branch name for the `next` branch. */ +const nextBranchName = 'master'; +/** Fetches the active release trains for the configured project. */ +function fetchActiveReleaseTrains(repo) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const nextVersion = yield getVersionOfBranch(repo, nextBranchName); + const next = new ReleaseTrain(nextBranchName, nextVersion); + const majorVersionsToConsider = []; + let expectedReleaseCandidateMajor; + // If the `next` branch (i.e. `master` branch) is for an upcoming major version, we know + // that there is no patch branch or feature-freeze/release-candidate branch for this major + // digit. If the current `next` version is the first minor of a major version, we know that + // the feature-freeze/release-candidate branch can only be the actual major branch. The + // patch branch is based on that, either the actual major branch or the last minor from the + // preceding major version. In all other cases, the patch branch and feature-freeze or + // release-candidate branch are part of the same major version. Consider the following: + // + // CASE 1. next: 11.0.0-next.0: patch and feature-freeze/release-candidate can only be + // most recent `10.<>.x` branches. The FF/RC branch can only be the last-minor of v10. + // CASE 2. next: 11.1.0-next.0: patch can be either `11.0.x` or last-minor in v10 based + // on whether there is a feature-freeze/release-candidate branch (=> `11.0.x`). + // CASE 3. next: 10.6.0-next.0: patch can be either `10.5.x` or `10.4.x` based on whether + // there is a feature-freeze/release-candidate branch (=> `10.5.x`) + if (nextVersion.minor === 0) { + expectedReleaseCandidateMajor = nextVersion.major - 1; + majorVersionsToConsider.push(nextVersion.major - 1); + } + else if (nextVersion.minor === 1) { + expectedReleaseCandidateMajor = nextVersion.major; + majorVersionsToConsider.push(nextVersion.major, nextVersion.major - 1); + } + else { + expectedReleaseCandidateMajor = nextVersion.major; + majorVersionsToConsider.push(nextVersion.major); + } + // Collect all version-branches that should be considered for the latest version-branch, + // or the feature-freeze/release-candidate. + const branches = (yield getBranchesForMajorVersions(repo, majorVersionsToConsider)); + const { latest, releaseCandidate } = yield findActiveReleaseTrainsFromVersionBranches(repo, nextVersion, branches, expectedReleaseCandidateMajor); + if (latest === null) { + throw Error(`Unable to determine the latest release-train. The following branches ` + + `have been considered: [${branches.map(b => b.name).join(', ')}]`); + } + return { releaseCandidate, latest, next }; + }); +} +/** Finds the currently active release trains from the specified version branches. */ +function findActiveReleaseTrainsFromVersionBranches(repo, nextVersion, branches, expectedReleaseCandidateMajor) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // Version representing the release-train currently in the next phase. Note that we ignore + // patch and pre-release segments in order to be able to compare the next release train to + // other release trains from version branches (which follow the `N.N.x` pattern). + const nextReleaseTrainVersion = semver.parse(`${nextVersion.major}.${nextVersion.minor}.0`); + let latest = null; + let releaseCandidate = null; + // Iterate through the captured branches and find the latest non-prerelease branch and a + // potential release candidate branch. From the collected branches we iterate descending + // order (most recent semantic version-branch first). The first branch is either the latest + // active version branch (i.e. patch) or a feature-freeze/release-candidate branch. A FF/RC + // branch cannot be older than the latest active version-branch, so we stop iterating once + // we found such a branch. Otherwise, if we found a FF/RC branch, we continue looking for the + // next version-branch as that one is supposed to be the latest active version-branch. If it + // is not, then an error will be thrown due to two FF/RC branches existing at the same time. + for (const { name, parsed } of branches) { + // It can happen that version branches have been accidentally created which are more recent + // than the release-train in the next branch (i.e. `master`). We could ignore such branches + // silently, but it might be symptomatic for an outdated version in the `next` branch, or an + // accidentally created branch by the caretaker. In either way we want to raise awareness. + if (semver.gt(parsed, nextReleaseTrainVersion)) { + throw Error(`Discovered unexpected version-branch "${name}" for a release-train that is ` + + `more recent than the release-train currently in the "${nextBranchName}" branch. ` + + `Please either delete the branch if created by accident, or update the outdated ` + + `version in the next branch (${nextBranchName}).`); + } + else if (semver.eq(parsed, nextReleaseTrainVersion)) { + throw Error(`Discovered unexpected version-branch "${name}" for a release-train that is already ` + + `active in the "${nextBranchName}" branch. Please either delete the branch if ` + + `created by accident, or update the version in the next branch (${nextBranchName}).`); + } + const version = yield getVersionOfBranch(repo, name); + const releaseTrain = new ReleaseTrain(name, version); + const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next'; + if (isPrerelease) { + if (releaseCandidate !== null) { + throw Error(`Unable to determine latest release-train. Found two consecutive ` + + `branches in feature-freeze/release-candidate phase. Did not expect both "${name}" ` + + `and "${releaseCandidate.branchName}" to be in feature-freeze/release-candidate mode.`); + } + else if (version.major !== expectedReleaseCandidateMajor) { + throw Error(`Discovered unexpected old feature-freeze/release-candidate branch. Expected no ` + + `version-branch in feature-freeze/release-candidate mode for v${version.major}.`); + } + releaseCandidate = releaseTrain; + } + else { + latest = releaseTrain; + break; + } + } + return { releaseCandidate, latest }; + }); +} + +/** + * @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 + */ +/** + * Cache for requested NPM package information. A cache is desirable as the NPM + * registry requests are usually very large and slow. + */ +const _npmPackageInfoCache = {}; +/** + * Fetches the NPM package representing the project. Angular repositories usually contain + * multiple packages in a monorepo scheme, but packages dealt with as part of the release + * tooling are released together with the same versioning and branching. This means that + * a single package can be used as source of truth for NPM package queries. + */ +function fetchProjectNpmPackageInfo(config) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const pkgName = getRepresentativeNpmPackage(config); + return yield fetchPackageInfoFromNpmRegistry(pkgName); + }); +} +/** Gets whether the given version is published to NPM or not */ +function isVersionPublishedToNpm(version, config) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { versions } = yield fetchProjectNpmPackageInfo(config); + return versions[version.format()] !== undefined; + }); +} +/** + * Gets the representative NPM package for the specified release configuration. Angular + * repositories usually contain multiple packages in a monorepo scheme, but packages dealt with + * as part of the release tooling are released together with the same versioning and branching. + * This means that a single package can be used as source of truth for NPM package queries. + */ +function getRepresentativeNpmPackage(config) { + return config.npmPackages[0]; +} +/** Fetches the specified NPM package from the NPM registry. */ +function fetchPackageInfoFromNpmRegistry(pkgName) { + return tslib.__awaiter(this, void 0, void 0, function* () { + if (_npmPackageInfoCache[pkgName] === undefined) { + _npmPackageInfoCache[pkgName] = + fetch(`https://registry.npmjs.org/${pkgName}`).then(r => r.json()); + } + return yield _npmPackageInfoCache[pkgName]; + }); +} + +/** + * @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 + */ +/** + * Number of months a major version in Angular is actively supported. See: + * https://angular.io/guide/releases#support-policy-and-schedule. + */ +const majorActiveSupportDuration = 6; +/** + * Number of months a major version has active long-term support. See: + * https://angular.io/guide/releases#support-policy-and-schedule. + */ +const majorLongTermSupportDuration = 12; +/** Regular expression that matches LTS NPM dist tags. */ +const ltsNpmDistTagRegex = /^v(\d+)-lts$/; +/** Finds all long-term support release trains from the specified NPM package. */ +function fetchLongTermSupportBranchesFromNpm(config) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { 'dist-tags': distTags, time } = yield fetchProjectNpmPackageInfo(config); + const today = new Date(); + const active = []; + const inactive = []; + // Iterate through the NPM package information and determine active/inactive LTS versions with + // their corresponding branches. We assume that an LTS tagged version in NPM belongs to the + // last-minor branch of a given major (i.e. we assume there are no outdated LTS NPM dist tags). + for (const npmDistTag in distTags) { + if (ltsNpmDistTagRegex.test(npmDistTag)) { + const version = semver.parse(distTags[npmDistTag]); + const branchName = `${version.major}.${version.minor}.x`; + const majorReleaseDate = new Date(time[`${version.major}.0.0`]); + const ltsEndDate = computeLtsEndDateOfMajor(majorReleaseDate); + const ltsBranch = { name: branchName, version, npmDistTag }; + // Depending on whether the LTS phase is still active, add the branch + // to the list of active or inactive LTS branches. + if (today <= ltsEndDate) { + active.push(ltsBranch); + } + else { + inactive.push(ltsBranch); + } + } + } + // Sort LTS branches in descending order. i.e. most recent ones first. + active.sort((a, b) => semver.rcompare(a.version, b.version)); + inactive.sort((a, b) => semver.rcompare(a.version, b.version)); + return { active, inactive }; + }); +} +/** + * Computes the date when long-term support ends for a major released at the + * specified date. + */ +function computeLtsEndDateOfMajor(majorReleaseDate) { + return new Date(majorReleaseDate.getFullYear(), majorReleaseDate.getMonth() + majorActiveSupportDuration + majorLongTermSupportDuration, majorReleaseDate.getDate(), majorReleaseDate.getHours(), majorReleaseDate.getMinutes(), majorReleaseDate.getSeconds(), majorReleaseDate.getMilliseconds()); +} +/** Gets the long-term support NPM dist tag for a given major version. */ +function getLtsNpmDistTagOfMajor(major) { + // LTS versions should be tagged in NPM in the following format: `v{major}-lts`. + return `v${major}-lts`; +} + +/** + * @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 + */ +/** Retrieve and log status of CI for the project. */ +function printCiStatus(git) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const releaseTrains = yield fetchActiveReleaseTrains(Object.assign({ api: git.github }, git.remoteConfig)); + info.group(bold(`CI`)); + for (const [trainName, train] of Object.entries(releaseTrains)) { + if (train === null) { + debug(`No active release train for ${trainName}`); + continue; + } + const status = yield getStatusOfBranch(git, train.branchName); + yield printStatus(`${trainName.padEnd(6)} (${train.branchName})`, status); + } + info.groupEnd(); + info(); + }); +} +/** Log the status of CI for a given branch to the console. */ +function printStatus(label, status) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const branchName = label.padEnd(16); + if (status === null) { + info(`${branchName} was not found on CircleCI`); + } + else if (status.status === 'success') { + info(`${branchName} ✅`); + } + else { + info(`${branchName} ❌`); + } + }); +} +/** Get the CI status of a given branch from CircleCI. */ +function getStatusOfBranch(git, branch) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { owner, name } = git.remoteConfig; + const url = `https://circleci.com/gh/${owner}/${name}/tree/${branch}.svg?style=shield`; + const result = yield fetch(url).then(result => result.text()); + if (result && !result.includes('no builds')) { + return { + status: result.includes('passing') ? 'success' : 'failed', + }; + } + return null; + }); +} + +/** + * @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 + */ +/** Compare the upstream master to the upstream g3 branch, if it exists. */ +function printG3Comparison(git) { + var _a, _b, _c, _d; + return tslib.__awaiter(this, void 0, void 0, function* () { + const angularRobotFilePath = path.join(getRepoBaseDir(), '.github/angular-robot.yml'); + if (!fs.existsSync(angularRobotFilePath)) { + return debug('No angular robot configuration file exists, skipping.'); + } + /** The configuration defined for the angular robot. */ + const robotConfig = yaml.parse(fs.readFileSync(angularRobotFilePath).toString()); + /** The files to be included in the g3 sync. */ + const includeFiles = ((_b = (_a = robotConfig === null || robotConfig === void 0 ? void 0 : robotConfig.merge) === null || _a === void 0 ? void 0 : _a.g3Status) === null || _b === void 0 ? void 0 : _b.include) || []; + /** The files to be expected in the g3 sync. */ + const excludeFiles = ((_d = (_c = robotConfig === null || robotConfig === void 0 ? void 0 : robotConfig.merge) === null || _c === void 0 ? void 0 : _c.g3Status) === null || _d === void 0 ? void 0 : _d.exclude) || []; + if (includeFiles.length === 0 && excludeFiles.length === 0) { + debug('No g3Status include or exclude lists are defined in the angular robot configuration,'); + debug('skipping.'); + return; + } + /** The latest sha for the g3 branch. */ + const g3Ref = getShaForBranchLatest('g3'); + /** The latest sha for the master branch. */ + const masterRef = getShaForBranchLatest('master'); + if (!g3Ref && !masterRef) { + return debug('Exiting early as either the g3 or master was unable to be retrieved'); + } + /** The statistical information about the git diff between master and g3. */ + const stats = getDiffStats(); + info.group(bold('g3 branch check')); + info(`${stats.commits} commits between g3 and master`); + if (stats.files === 0) { + info('✅ No sync is needed at this time'); + } + else { + info(`${stats.files} files changed, ${stats.insertions} insertions(+), ${stats.deletions} deletions(-) will be included in the next sync`); + } + info.groupEnd(); + info(); + /** Fetch and retrieve the latest sha for a specific branch. */ + function getShaForBranchLatest(branch) { + /** The result fo the fetch command. */ + const fetchResult = git.runGraceful([ + 'fetch', '-q', `https://github.com/${git.remoteConfig.owner}/${git.remoteConfig.name}.git`, + branch + ]); + if (fetchResult.status !== 0 && + fetchResult.stderr.includes(`couldn't find remote ref ${branch}`)) { + debug(`No '${branch}' branch exists on upstream, skipping.`); + return false; + } + return git.runGraceful(['rev-parse', 'FETCH_HEAD']).stdout.trim(); + } + /** + * Get git diff stats between master and g3, for all files and filtered to only g3 affecting + * files. + */ + function getDiffStats() { + /** The diff stats to be returned. */ + const stats = { + insertions: 0, + deletions: 0, + files: 0, + commits: 0, + }; + // Determine the number of commits between master and g3 refs. */ + stats.commits = parseInt(git.run(['rev-list', '--count', `${g3Ref}..${masterRef}`]).stdout, 10); + // Get the numstat information between master and g3 + git.run(['diff', `${g3Ref}...${masterRef}`, '--numstat']) + .stdout + // Remove the extra space after git's output. + .trim() + // Split each line of git output into array + .split('\n') + // Split each line from the git output into components parts: insertions, + // deletions and file name respectively + .map(line => line.split('\t')) + // Parse number value from the insertions and deletions values + // Example raw line input: + // 10\t5\tsrc/file/name.ts + .map(line => [Number(line[0]), Number(line[1]), line[2]]) + // Add each line's value to the diff stats, and conditionally to the g3 + // stats as well if the file name is included in the files synced to g3. + .forEach(([insertions, deletions, fileName]) => { + if (checkMatchAgainstIncludeAndExclude(fileName, includeFiles, excludeFiles)) { + stats.insertions += insertions; + stats.deletions += deletions; + stats.files += 1; + } + }); + return stats; + } + /** Determine whether the file name passes both include and exclude checks. */ + function checkMatchAgainstIncludeAndExclude(file, includes, excludes) { + return (multimatch.call(undefined, file, includes).length >= 1 && + multimatch.call(undefined, file, excludes).length === 0); + } + }); +} + +/** + * @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 + */ +/** + * Cap the returned issues in the queries to an arbitrary 100. At that point, caretaker has a lot + * of work to do and showing more than that isn't really useful. + */ +const MAX_RETURNED_ISSUES = 20; +/** Retrieve the number of matching issues for each github query. */ +function printGithubTasks(git, config) { + var _a; + return tslib.__awaiter(this, void 0, void 0, function* () { + if (!((_a = config === null || config === void 0 ? void 0 : config.githubQueries) === null || _a === void 0 ? void 0 : _a.length)) { + debug('No github queries defined in the configuration, skipping.'); + return; + } + info.group(bold(`Github Tasks`)); + yield getGithubInfo(git, config); + info.groupEnd(); + info(); + }); +} +/** Retrieve query match counts and log discovered counts to the console. */ +function getGithubInfo(git, { githubQueries: queries = [] }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + /** The query object for graphql. */ + const graphQlQuery = {}; + /** The Github search filter for the configured repository. */ + const repoFilter = `repo:${git.remoteConfig.owner}/${git.remoteConfig.name}`; + queries.forEach(({ name, query }) => { + /** The name of the query, with spaces removed to match GraphQL requirements. */ + const queryKey = typedGraphqlify.alias(name.replace(/ /g, ''), 'search'); + graphQlQuery[queryKey] = typedGraphqlify.params({ + type: 'ISSUE', + first: MAX_RETURNED_ISSUES, + query: `"${repoFilter} ${query.replace(/"/g, '\\"')}"`, + }, { + issueCount: typedGraphqlify.types.number, + nodes: [Object.assign({}, typedGraphqlify.onUnion({ + PullRequest: { + url: typedGraphqlify.types.string, + }, + Issue: { + url: typedGraphqlify.types.string, + }, + }))], + }); + }); + /** The results of the generated github query. */ + const results = yield git.github.graphql.query(graphQlQuery); + Object.values(results).forEach((result, i) => { + var _a, _b; + info(`${(_a = queries[i]) === null || _a === void 0 ? void 0 : _a.name.padEnd(25)} ${result.issueCount}`); + if (result.issueCount > 0) { + const { owner, name: repo } = git.remoteConfig; + const url = encodeURI(`https://github.com/${owner}/${repo}/issues?q=${(_b = queries[i]) === null || _b === void 0 ? void 0 : _b.query}`); + info.group(`${url}`); + if (result.nodes.length === MAX_RETURNED_ISSUES && result.nodes.length < result.issueCount) { + info(`(first ${MAX_RETURNED_ISSUES})`); + } + for (const node of result.nodes) { + info(`- ${node.url}`); + } + 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 + */ +/** The status levels for services. */ +var ServiceStatus; +(function (ServiceStatus) { + ServiceStatus[ServiceStatus["GREEN"] = 0] = "GREEN"; + ServiceStatus[ServiceStatus["RED"] = 1] = "RED"; +})(ServiceStatus || (ServiceStatus = {})); +/** Retrieve and log stasuses for all of the services of concern. */ +function printServiceStatuses() { + return tslib.__awaiter(this, void 0, void 0, function* () { + info.group(bold(`Service Statuses (checked: ${new Date().toLocaleString()})`)); + logStatus('CircleCI', yield getCircleCiStatus()); + logStatus('Github', yield getGithubStatus()); + logStatus('NPM', yield getNpmStatus()); + logStatus('Saucelabs', yield getSaucelabsStatus()); + info.groupEnd(); + info(); + }); +} +/** Log the status of the service to the console. */ +function logStatus(serviceName, status) { + serviceName = serviceName.padEnd(15); + if (status.status === ServiceStatus.GREEN) { + info(`${serviceName} ${green('✅')}`); + } + else if (status.status === ServiceStatus.RED) { + info.group(`${serviceName} ${red('❌')} (Updated: ${status.lastUpdated.toLocaleString()})`); + info(` Details: ${status.description}`); + info.groupEnd(); + } +} +/** Gets the service status information for Saucelabs. */ +function getSaucelabsStatus() { + return tslib.__awaiter(this, void 0, void 0, function* () { + return getStatusFromStandardApi('https://status.us-west-1.saucelabs.com/api/v2/status.json'); + }); +} +/** Gets the service status information for NPM. */ +function getNpmStatus() { + return tslib.__awaiter(this, void 0, void 0, function* () { + return getStatusFromStandardApi('https://status.npmjs.org/api/v2/status.json'); + }); +} +/** Gets the service status information for CircleCI. */ +function getCircleCiStatus() { + return tslib.__awaiter(this, void 0, void 0, function* () { + return getStatusFromStandardApi('https://status.circleci.com/api/v2/status.json'); + }); +} +/** Gets the service status information for Github. */ +function getGithubStatus() { + return tslib.__awaiter(this, void 0, void 0, function* () { + return getStatusFromStandardApi('https://www.githubstatus.com/api/v2/status.json'); + }); +} +/** Retrieve the status information for a service which uses a standard API response. */ +function getStatusFromStandardApi(url) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const result = yield fetch(url).then(result => result.json()); + const status = result.status.indicator === 'none' ? ServiceStatus.GREEN : ServiceStatus.RED; + return { + status, + description: result.status.description, + lastUpdated: new Date(result.page.updated_at) + }; + }); +} + +/** + * @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 + */ +/** Check the status of services which Angular caretakers need to monitor. */ +function checkServiceStatuses(githubToken) { + return tslib.__awaiter(this, void 0, void 0, function* () { + /** The configuration for the caretaker commands. */ + const config = getCaretakerConfig(); + /** The GitClient for interacting with git and Github. */ + const git = new GitClient(githubToken, config); + // TODO(josephperrott): Allow these checks to be loaded in parallel. + yield printServiceStatuses(); + yield printGithubTasks(git, config.caretaker); + yield printG3Comparison(git); + yield printCiStatus(git); + }); +} + +/** + * @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(yargs) { + return addGithubTokenOption(yargs); +} +/** Handles the command. */ +function handler({ githubToken }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + yield checkServiceStatuses(githubToken); + }); +} +/** yargs command module for checking status information for the repository */ +const CheckModule = { + handler, + builder, + command: 'check', + describe: 'Check the status of information the caretaker manages for the repository', +}; + +/** + * @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 + */ +/** Build the parser for the caretaker commands. */ +function buildCaretakerParser(yargs) { + return yargs.command(CheckModule); +} + +/** + * @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 + */ +/** Load the commit message draft from the file system if it exists. */ +function loadCommitMessageDraft(basePath) { + const commitMessageDraftPath = `${basePath}.ngDevSave`; + if (fs.existsSync(commitMessageDraftPath)) { + return fs.readFileSync(commitMessageDraftPath).toString(); + } + return ''; +} +/** Remove the commit message draft from the file system. */ +function deleteCommitMessageDraft(basePath) { + const commitMessageDraftPath = `${basePath}.ngDevSave`; + if (fs.existsSync(commitMessageDraftPath)) { + fs.unlinkSync(commitMessageDraftPath); + } +} +/** Save the commit message draft to the file system for later retrieval. */ +function saveCommitMessageDraft(basePath, commitMessage) { + fs.writeFileSync(`${basePath}.ngDevSave`, commitMessage); +} + +/** + * @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 + */ +/** + * Restore the commit message draft to the git to be used as the default commit message. + * + * The source provided may be one of the sources described in + * https://git-scm.com/docs/githooks#_prepare_commit_msg + */ +function restoreCommitMessage(filePath, source) { + if (!!source) { + log('Skipping commit message restoration attempt'); + if (source === 'message') { + debug('A commit message was already provided via the command with a -m or -F flag'); + } + if (source === 'template') { + debug('A commit message was already provided via the -t flag or config.template setting'); + } + if (source === 'squash') { + debug('A commit message was already provided as a merge action or via .git/MERGE_MSG'); + } + if (source === 'commit') { + debug('A commit message was already provided through a revision specified via --fixup, -c,'); + debug('-C or --amend flag'); + } + process.exit(0); + } + /** A draft of a commit message. */ + const commitMessage = loadCommitMessageDraft(filePath); + // If the commit message draft has content, restore it into the provided filepath. + if (commitMessage) { + fs.writeFileSync(filePath, commitMessage); + } + // Exit the process + process.exit(0); +} + +/** + * @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$1(yargs) { + return yargs.option('file-env-variable', { + type: 'string', + array: true, + demandOption: true, + description: 'The key for the environment variable which holds the arguments for the\n' + + 'prepare-commit-msg hook as described here:\n' + + 'https://git-scm.com/docs/githooks#_prepare_commit_msg', + coerce: arg => { + const [file, source] = (process.env[arg] || '').split(' '); + if (!file) { + throw new Error(`Provided environment variable "${arg}" was not found.`); + } + return [file, source]; + }, + }); +} +/** Handles the command. */ +function handler$1({ fileEnvVariable }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + restoreCommitMessage(fileEnvVariable[0], fileEnvVariable[1]); + }); +} +/** yargs command module describing the command. */ +const RestoreCommitMessageModule = { + handler: handler$1, + builder: builder$1, + command: 'restore-commit-message-draft', + // Description: Restore a commit message draft if one has been saved from a failed commit attempt. + // No describe is defiend to hide the command from the --help. + describe: false, +}; + +/** + * @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 + */ +/** Retrieve and validate the config as `CommitMessageConfig`. */ +function getCommitMessageConfig() { + // List of errors encountered validating the config. + const errors = []; + // The non-validated config object. + const config = getConfig(); + if (config.commitMessage === undefined) { + errors.push(`No configuration defined for "commitMessage"`); + } + assertNoErrors(errors); + return config; +} +/** Scope requirement level to be set for each commit type. */ +var ScopeRequirement; +(function (ScopeRequirement) { + ScopeRequirement[ScopeRequirement["Required"] = 0] = "Required"; + ScopeRequirement[ScopeRequirement["Optional"] = 1] = "Optional"; + ScopeRequirement[ScopeRequirement["Forbidden"] = 2] = "Forbidden"; +})(ScopeRequirement || (ScopeRequirement = {})); +/** The valid commit types for Angular commit messages. */ +const COMMIT_TYPES = { + build: { + name: 'build', + description: 'Changes to local repository build system and tooling', + scope: ScopeRequirement.Optional, + }, + ci: { + name: 'ci', + description: 'Changes to CI configuration and CI specific tooling', + scope: ScopeRequirement.Forbidden, + }, + docs: { + name: 'docs', + description: 'Changes which exclusively affects documentation.', + scope: ScopeRequirement.Optional, + }, + feat: { + name: 'feat', + description: 'Creates a new feature', + scope: ScopeRequirement.Required, + }, + fix: { + name: 'fix', + description: 'Fixes a previously discovered failure/bug', + scope: ScopeRequirement.Required, + }, + perf: { + name: 'perf', + description: 'Improves performance without any change in functionality or API', + scope: ScopeRequirement.Required, + }, + refactor: { + name: 'refactor', + description: 'Refactor without any change in functionality or API (includes style changes)', + scope: ScopeRequirement.Required, + }, + release: { + name: 'release', + description: 'A release point in the repository', + scope: ScopeRequirement.Forbidden, + }, + test: { + name: 'test', + description: 'Improvements or corrections made to the project\'s test suite', + scope: ScopeRequirement.Required, + }, +}; + +/** + * @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 + */ +/** Regex determining if a commit is a fixup. */ +const FIXUP_PREFIX_RE = /^fixup! /i; +/** Regex finding all github keyword links. */ +const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig; +/** Regex determining if a commit is a squash. */ +const SQUASH_PREFIX_RE = /^squash! /i; +/** Regex determining if a commit is a revert. */ +const REVERT_PREFIX_RE = /^revert:? /i; +/** Regex determining the scope of a commit if provided. */ +const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/; +/** Regex determining the entire header line of the commit. */ +const COMMIT_HEADER_RE = /^(.*)/i; +/** Regex determining the body of the commit. */ +const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/; +/** Parse a full commit message into its composite parts. */ +function parseCommitMessage(commitMsg) { + // Ignore comments (i.e. lines starting with `#`). Comments are automatically removed by git and + // should not be considered part of the final commit message. + commitMsg = commitMsg.split('\n').filter(line => !line.startsWith('#')).join('\n'); + let header = ''; + let body = ''; + let bodyWithoutLinking = ''; + let type = ''; + let scope = ''; + let subject = ''; + if (COMMIT_HEADER_RE.test(commitMsg)) { + header = COMMIT_HEADER_RE.exec(commitMsg)[1] + .replace(FIXUP_PREFIX_RE, '') + .replace(SQUASH_PREFIX_RE, ''); + } + if (COMMIT_BODY_RE.test(commitMsg)) { + body = COMMIT_BODY_RE.exec(commitMsg)[1]; + bodyWithoutLinking = body.replace(GITHUB_LINKING_RE, ''); + } + if (TYPE_SCOPE_RE.test(header)) { + const parsedCommitHeader = TYPE_SCOPE_RE.exec(header); + type = parsedCommitHeader[1]; + scope = parsedCommitHeader[2]; + subject = parsedCommitHeader[3]; + } + return { + header, + body, + bodyWithoutLinking, + type, + scope, + subject, + isFixup: FIXUP_PREFIX_RE.test(commitMsg), + isSquash: SQUASH_PREFIX_RE.test(commitMsg), + isRevert: REVERT_PREFIX_RE.test(commitMsg), + }; +} + +/** + * @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 + */ +/** Regex matching a URL for an entire commit body line. */ +const COMMIT_BODY_URL_LINE_RE = /^https?:\/\/.*$/; +/** Validate a commit message against using the local repo's config. */ +function validateCommitMessage(commitMsg, options = {}) { + const config = getCommitMessageConfig().commitMessage; + const commit = parseCommitMessage(commitMsg); + const errors = []; + /** Perform the validation checks against the parsed commit. */ + function validateCommitAndCollectErrors() { + // TODO(josephperrott): Remove early return calls when commit message errors are found + var _a; + //////////////////////////////////// + // Checking revert, squash, fixup // + //////////////////////////////////// + // All revert commits are considered valid. + if (commit.isRevert) { + return true; + } + // All squashes are considered valid, as the commit will be squashed into another in + // the git history anyway, unless the options provided to not allow squash commits. + if (commit.isSquash) { + if (options.disallowSquash) { + errors.push('The commit must be manually squashed into the target commit'); + return false; + } + return true; + } + // Fixups commits are considered valid, unless nonFixupCommitHeaders are provided to check + // against. If `nonFixupCommitHeaders` is not empty, we check whether there is a corresponding + // non-fixup commit (i.e. a commit whose header is identical to this commit's header after + // stripping the `fixup! ` prefix), otherwise we assume this verification will happen in another + // check. + if (commit.isFixup) { + if (options.nonFixupCommitHeaders && !options.nonFixupCommitHeaders.includes(commit.header)) { + errors.push('Unable to find match for fixup commit among prior commits: ' + + (options.nonFixupCommitHeaders.map(x => `\n ${x}`).join('') || '-')); + return false; + } + return true; + } + //////////////////////////// + // Checking commit header // + //////////////////////////// + if (commit.header.length > config.maxLineLength) { + errors.push(`The commit message header is longer than ${config.maxLineLength} characters`); + return false; + } + if (!commit.type) { + errors.push(`The commit message header does not match the expected format.`); + return false; + } + if (COMMIT_TYPES[commit.type] === undefined) { + errors.push(`'${commit.type}' is not an allowed type.\n => TYPES: ${Object.keys(COMMIT_TYPES).join(', ')}`); + return false; + } + /** The scope requirement level for the provided type of the commit message. */ + const scopeRequirementForType = COMMIT_TYPES[commit.type].scope; + if (scopeRequirementForType === ScopeRequirement.Forbidden && commit.scope) { + errors.push(`Scopes are forbidden for commits with type '${commit.type}', but a scope of '${commit.scope}' was provided.`); + return false; + } + if (scopeRequirementForType === ScopeRequirement.Required && !commit.scope) { + errors.push(`Scopes are required for commits with type '${commit.type}', but no scope was provided.`); + return false; + } + if (commit.scope && !config.scopes.includes(commit.scope)) { + errors.push(`'${commit.scope}' is not an allowed scope.\n => SCOPES: ${config.scopes.join(', ')}`); + return false; + } + // Commits with the type of `release` do not require a commit body. + if (commit.type === 'release') { + return true; + } + ////////////////////////// + // Checking commit body // + ////////////////////////// + if (!((_a = config.minBodyLengthTypeExcludes) === null || _a === void 0 ? void 0 : _a.includes(commit.type)) && + commit.bodyWithoutLinking.trim().length < config.minBodyLength) { + errors.push(`The commit message body does not meet the minimum length of ${config.minBodyLength} characters`); + return false; + } + const bodyByLine = commit.body.split('\n'); + const lineExceedsMaxLength = bodyByLine.some(line => { + // Check if any line exceeds the max line length limit. The limit is ignored for + // lines that just contain an URL (as these usually cannot be wrapped or shortened). + return line.length > config.maxLineLength && !COMMIT_BODY_URL_LINE_RE.test(line); + }); + if (lineExceedsMaxLength) { + errors.push(`The commit message body contains lines greater than ${config.maxLineLength} characters`); + return false; + } + return true; + } + return { valid: validateCommitAndCollectErrors(), errors, commit }; +} +/** Print the error messages from the commit message validation to the console. */ +function printValidationErrors(errors, print = error) { + print.group(`Error${errors.length === 1 ? '' : 's'}:`); + errors.forEach(line => print(line)); + print.groupEnd(); + print(); + print('The expected format for a commit is: '); + print('(): '); + print(); + print(''); + print(); +} + +/** + * @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 + */ +/** Validate commit message at the provided file path. */ +function validateFile(filePath, isErrorMode) { + const commitMessage = fs.readFileSync(path.resolve(getRepoBaseDir(), filePath), 'utf8'); + const { valid, errors } = validateCommitMessage(commitMessage); + if (valid) { + info(`${green('√')} Valid commit message`); + deleteCommitMessageDraft(filePath); + process.exitCode = 0; + return; + } + /** Function used to print to the console log. */ + let printFn = isErrorMode ? error : log; + printFn(`${isErrorMode ? red('✘') : yellow('!')} Invalid commit message`); + printValidationErrors(errors, printFn); + if (isErrorMode) { + printFn(red('Aborting commit attempt due to invalid commit message.')); + printFn(red('Commit message aborted as failure rather than warning due to local configuration.')); + } + else { + printFn(yellow('Before this commit can be merged into the upstream repository, it must be')); + printFn(yellow('amended to follow commit message guidelines.')); + } + // On all invalid commit messages, the commit message should be saved as a draft to be + // restored on the next commit attempt. + saveCommitMessageDraft(filePath, commitMessage); + // Set the correct exit code based on if invalid commit message is an error. + process.exitCode = isErrorMode ? 1 : 0; +} + +/** + * @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$2(yargs) { + var _a; + return yargs + .option('file', { + type: 'string', + conflicts: ['file-env-variable'], + description: 'The path of the commit message file.', + }) + .option('file-env-variable', { + type: 'string', + conflicts: ['file'], + description: 'The key of the environment variable for the path of the commit message file.', + coerce: (arg) => { + const file = process.env[arg]; + if (!file) { + throw new Error(`Provided environment variable "${arg}" was not found.`); + } + return file; + }, + }) + .option('error', { + type: 'boolean', + description: 'Whether invalid commit messages should be treated as failures rather than a warning', + default: !!((_a = getUserConfig().commitMessage) === null || _a === void 0 ? void 0 : _a.errorOnInvalidMessage) || !!process.env['CI'] + }); +} +/** Handles the command. */ +function handler$2({ error, file, fileEnvVariable }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const filePath = file || fileEnvVariable || '.git/COMMIT_EDITMSG'; + validateFile(filePath, error); + }); +} +/** yargs command module describing the command. */ +const ValidateFileModule = { + handler: handler$2, + builder: builder$2, + command: 'pre-commit-validate', + describe: 'Validate the most recent commit message', +}; + +/** + * @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 + */ +// Whether the provided commit is a fixup commit. +const isNonFixup = (m) => !parseCommitMessage(m).isFixup; +// Extracts commit header (first line of commit message). +const extractCommitHeader = (m) => parseCommitMessage(m).header; +/** Validate all commits in a provided git commit range. */ +function validateCommitRange(range) { + /** + * A random value is used as a string to allow for a definite split point in the git log result. + */ + const randomValueSeparator = `${Math.random()}`; + /** + * Custom git log format that provides the commit header and body, separated as expected with the + * custom separator as the trailing value. + */ + const gitLogFormat = `%s%n%n%b${randomValueSeparator}`; + /** + * A list of tuples containing a commit header string and the list of error messages for the + * commit. + */ + const errors = []; + // Retrieve the commits in the provided range. + const result = exec(`git log --reverse --format=${gitLogFormat} ${range}`); + if (result.code) { + throw new Error(`Failed to get all commits in the range: \n ${result.stderr}`); + } + // Separate the commits from a single string into individual commits + const commits = result.split(randomValueSeparator).map(l => l.trim()).filter(line => !!line); + info(`Examining ${commits.length} commit(s) in the provided range: ${range}`); + // Check each commit in the commit range. Commits are allowed to be fixup commits for other + // commits in the provided commit range. + const allCommitsInRangeValid = commits.every((m, i) => { + const options = { + disallowSquash: true, + nonFixupCommitHeaders: isNonFixup(m) ? + undefined : + commits.slice(0, i).filter(isNonFixup).map(extractCommitHeader) + }; + const { valid, errors: localErrors, commit } = validateCommitMessage(m, options); + if (localErrors.length) { + errors.push([commit.header, localErrors]); + } + return valid; + }); + if (allCommitsInRangeValid) { + info('√ All commit messages in range valid.'); + } + else { + error('✘ Invalid commit message'); + errors.forEach(([header, validationErrors]) => { + error.group(header); + printValidationErrors(validationErrors); + error.groupEnd(); + }); + // Exit with a non-zero exit code if invalid commit messages have + // been discovered. + process.exit(1); + } +} + +/** + * @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$3(yargs) { + return yargs.option('range', { + description: 'The range of commits to check, e.g. --range abc123..xyz456', + demandOption: ' A range must be provided, e.g. --range abc123..xyz456', + type: 'string', + requiresArg: true, + }); +} +/** Handles the command. */ +function handler$3({ range }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // If on CI, and no pull request number is provided, assume the branch + // being run on is an upstream branch. + if (process.env['CI'] && process.env['CI_PULL_REQUEST'] === 'false') { + info(`Since valid commit messages are enforced by PR linting on CI, we do not`); + info(`need to validate commit messages on CI runs on upstream branches.`); + info(); + info(`Skipping check of provided commit range`); + return; + } + validateCommitRange(range); + }); +} +/** yargs command module describing the command. */ +const ValidateRangeModule = { + handler: handler$3, + builder: builder$3, + command: 'validate-range', + describe: 'Validate a range of commit messages', +}; + +/** Validate commit message at the provided file path. */ +function buildCommitMessage() { + return tslib.__awaiter(this, void 0, void 0, function* () { + // TODO(josephperrott): Add support for skipping wizard with local untracked config file + // TODO(josephperrott): Add default commit message information/commenting into generated messages + info('Just a few questions to start building the commit message!'); + /** The commit message type. */ + const type = yield promptForCommitMessageType(); + /** The commit message scope. */ + const scope = yield promptForCommitMessageScopeForType(type); + /** The commit message summary. */ + const summary = yield promptForCommitMessageSummary(); + return `${type.name}${scope ? '(' + scope + ')' : ''}: ${summary}\n\n`; + }); +} +/** Prompts in the terminal for the commit message's type. */ +function promptForCommitMessageType() { + return tslib.__awaiter(this, void 0, void 0, function* () { + info('The type of change in the commit. Allows a reader to know the effect of the change,'); + info('whether it brings a new feature, adds additional testing, documents the `project, etc.'); + /** List of commit type options for the autocomplete prompt. */ + const typeOptions = Object.values(COMMIT_TYPES).map(({ description, name }) => { + return { + name: `${name} - ${description}`, + value: name, + short: name, + }; + }); + /** The key of a commit message type, selected by the user via prompt. */ + const typeName = yield promptAutocomplete('Select a type for the commit:', typeOptions); + return COMMIT_TYPES[typeName]; + }); +} +/** Prompts in the terminal for the commit message's scope. */ +function promptForCommitMessageScopeForType(type) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // If the commit type's scope requirement is forbidden, return early. + if (type.scope === ScopeRequirement.Forbidden) { + info(`Skipping scope selection as the '${type.name}' type does not allow scopes`); + return false; + } + /** Commit message configuration */ + const config = getCommitMessageConfig(); + info('The area of the repository the changes in this commit most affects.'); + return yield promptAutocomplete('Select a scope for the commit:', config.commitMessage.scopes, type.scope === ScopeRequirement.Optional ? '' : ''); + }); +} +/** Prompts in the terminal for the commit message's summary. */ +function promptForCommitMessageSummary() { + return tslib.__awaiter(this, void 0, void 0, function* () { + info('Provide a short summary of what the changes in the commit do'); + return yield promptInput('Provide a short summary of the commit'); + }); +} + +/** The default commit message used if the wizard does not procude a commit message. */ +const defaultCommitMessage = `(): + +# \n\n`; +function runWizard(args) { + var _a; + return tslib.__awaiter(this, void 0, void 0, function* () { + if ((_a = getUserConfig().commitMessage) === null || _a === void 0 ? void 0 : _a.disableWizard) { + debug('Skipping commit message wizard due to enabled `commitMessage.disableWizard` option in'); + debug('user config.'); + process.exitCode = 0; + return; + } + if (args.source !== undefined) { + info(`Skipping commit message wizard because the commit was created via '${args.source}' source`); + process.exitCode = 0; + return; + } + // Set the default commit message to be updated if the user cancels out of the wizard in progress + fs.writeFileSync(args.filePath, defaultCommitMessage); + /** The generated commit message. */ + const commitMessage = yield buildCommitMessage(); + fs.writeFileSync(args.filePath, commitMessage); + }); +} + +/** + * @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$4(yargs) { + return yargs + .positional('filePath', { + description: 'The file path to write the generated commit message into', + type: 'string', + demandOption: true, + }) + .positional('source', { + choices: ['message', 'template', 'merge', 'squash', 'commit'], + description: 'The source of the commit message as described here: ' + + 'https://git-scm.com/docs/githooks#_prepare_commit_msg' + }) + .positional('commitSha', { + description: 'The commit sha if source is set to `commit`', + type: 'string', + }); +} +/** Handles the command. */ +function handler$4(args) { + return tslib.__awaiter(this, void 0, void 0, function* () { + yield runWizard(args); + }); +} +/** yargs command module describing the command. */ +const WizardModule = { + handler: handler$4, + builder: builder$4, + command: 'wizard [source] [commitSha]', + // Description: Run the wizard to build a base commit message before opening to complete. + // No describe is defiend to hide the command from the --help. + describe: false, +}; + +/** Build the parser for the commit-message commands. */ +function buildCommitMessageParser(localYargs) { + return localYargs.help() + .strict() + .command(RestoreCommitMessageModule) + .command(WizardModule) + .command(ValidateFileModule) + .command(ValidateRangeModule); +} + +/** + * @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 + */ +/** + * A list of all files currently in the repo which have been modified since the provided sha. + * + * git diff + * Deleted files (--diff-filter=d) are not included as they are not longer present in the repo + * and can not be checked anymore. + * + * git ls-files + * Untracked files (--others), which are not matched by .gitignore (--exclude-standard) + * as they are expected to become tracked files. + */ +function allChangedFilesSince(sha) { + if (sha === void 0) { sha = 'HEAD'; } + var diffFiles = gitOutputAsArray("git diff --name-only --diff-filter=d " + sha); + var untrackedFiles = gitOutputAsArray("git ls-files --others --exclude-standard"); + // Use a set to deduplicate the list as its possible for a file to show up in both lists. + return Array.from(new Set(tslib.__spread(diffFiles, untrackedFiles))); +} +/** + * A list of all staged files which have been modified. + * + * Only added, created and modified files are listed as others (deleted, renamed, etc) aren't + * changed or available as content to act upon. + */ +function allStagedFiles() { + return gitOutputAsArray("git diff --staged --name-only --diff-filter=ACM"); +} +function allFiles() { + return gitOutputAsArray("git ls-files"); +} +function gitOutputAsArray(cmd) { + return exec(cmd, { cwd: getRepoBaseDir() }).split('\n').map(function (x) { return x.trim(); }).filter(function (x) { return !!x; }); +} + +/** + * @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 + */ +/** Retrieve and validate the config as `FormatConfig`. */ +function getFormatConfig() { + // List of errors encountered validating the config. + const errors = []; + // The unvalidated config object. + const config = getConfig(); + if (config.format === undefined) { + errors.push(`No configuration defined for "format"`); + } + for (const [key, value] of Object.entries(config.format)) { + switch (typeof value) { + case 'boolean': + break; + case 'object': + checkFormatterConfig(key, value, errors); + break; + default: + errors.push(`"format.${key}" is not a boolean or Formatter object`); + } + } + assertNoErrors(errors); + return config; +} +/** Validate an individual Formatter config. */ +function checkFormatterConfig(key, config, errors) { + if (config.matchers === undefined) { + errors.push(`Missing "format.${key}.matchers" 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 + */ +/** + * The base class for formatters to run against provided files. + */ +class Formatter { + constructor(config) { + this.config = config; + } + /** + * Retrieve the command to execute the provided action, including both the binary + * and command line flags. + */ + commandFor(action) { + switch (action) { + case 'check': + return `${this.binaryFilePath} ${this.actions.check.commandFlags}`; + case 'format': + return `${this.binaryFilePath} ${this.actions.format.commandFlags}`; + default: + throw Error('Unknown action type'); + } + } + /** + * Retrieve the callback for the provided action to determine if an action + * failed in formatting. + */ + callbackFor(action) { + switch (action) { + case 'check': + return this.actions.check.callback; + case 'format': + return this.actions.format.callback; + default: + throw Error('Unknown action type'); + } + } + /** Whether the formatter is enabled in the provided config. */ + isEnabled() { + return !!this.config[this.name]; + } + /** Retrieve the active file matcher for the formatter. */ + getFileMatcher() { + return this.getFileMatcherFromConfig() || this.defaultFileMatcher; + } + /** + * Retrieves the file matcher from the config provided to the constructor if provided. + */ + getFileMatcherFromConfig() { + const formatterConfig = this.config[this.name]; + if (typeof formatterConfig === 'boolean') { + return undefined; + } + return formatterConfig.matchers; + } +} + +/** + * @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 + */ +/** + * Formatter for running buildifier against bazel related files. + */ +class Buildifier extends Formatter { + constructor() { + super(...arguments); + this.name = 'buildifier'; + this.binaryFilePath = path.join(getRepoBaseDir(), 'node_modules/.bin/buildifier'); + this.defaultFileMatcher = ['**/*.bzl', '**/BUILD.bazel', '**/WORKSPACE', '**/BUILD']; + this.actions = { + check: { + commandFlags: `${BAZEL_WARNING_FLAG} --lint=warn --mode=check --format=json`, + callback: (_, code, stdout) => { + return code !== 0 || !JSON.parse(stdout)['success']; + }, + }, + format: { + commandFlags: `${BAZEL_WARNING_FLAG} --lint=fix --mode=fix`, + callback: (file, code, _, stderr) => { + if (code !== 0) { + error(`Error running buildifier on: ${file}`); + error(stderr); + error(); + return true; + } + return false; + } + } + }; + } +} +// The warning flag for buildifier copied from angular/angular's usage. +const BAZEL_WARNING_FLAG = `--warnings=attr-cfg,attr-license,attr-non-empty,attr-output-default,` + + `attr-single-file,constant-glob,ctx-args,depset-iteration,depset-union,dict-concatenation,` + + `duplicated-name,filetype,git-repository,http-archive,integer-division,load,load-on-top,` + + `native-build,native-package,output-group,package-name,package-on-top,positional-args,` + + `redefined-variable,repository-name,same-origin-load,string-iteration,unused-variable`; + +/** + * @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 + */ +/** + * Formatter for running clang-format against Typescript and Javascript files + */ +class ClangFormat extends Formatter { + constructor() { + super(...arguments); + this.name = 'clang-format'; + this.binaryFilePath = path.join(getRepoBaseDir(), 'node_modules/.bin/clang-format'); + this.defaultFileMatcher = ['**/*.{t,j}s']; + this.actions = { + check: { + commandFlags: `--Werror -n -style=file`, + callback: (_, code) => { + return code !== 0; + }, + }, + format: { + commandFlags: `-i -style=file`, + callback: (file, code, _, stderr) => { + if (code !== 0) { + error(`Error running clang-format on: ${file}`); + error(stderr); + error(); + return true; + } + return false; + } + } + }; + } +} + +/** + * @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 + */ +/** + * Get all defined formatters which are active based on the current loaded config. + */ +function getActiveFormatters() { + const config = getFormatConfig().format; + return [new Buildifier(config), new ClangFormat(config)].filter(formatter => formatter.isEnabled()); +} + +/** + * @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 + */ +const AVAILABLE_THREADS = Math.max(os.cpus().length - 1, 1); +/** + * Run the provided commands in parallel for each provided file. + * + * Running the formatter is split across (number of available cpu threads - 1) processess. + * The task is done in multiple processess to speed up the overall time of the task, as running + * across entire repositories takes a large amount of time. + * As a data point for illustration, using 8 process rather than 1 cut the execution + * time from 276 seconds to 39 seconds for the same 2700 files. + * + * A promise is returned, completed when the command has completed running for each file. + * The promise resolves with a list of failures, or `false` if no formatters have matched. + */ +function runFormatterInParallel(allFiles, action) { + return new Promise((resolve) => { + const formatters = getActiveFormatters(); + const failures = []; + const pendingCommands = []; + for (const formatter of formatters) { + pendingCommands.push(...multimatch.call(undefined, allFiles, formatter.getFileMatcher(), { dot: true }) + .map(file => ({ formatter, file }))); + } + // If no commands are generated, resolve the promise as `false` as no files + // were run against the any formatters. + if (pendingCommands.length === 0) { + return resolve(false); + } + switch (action) { + case 'format': + info(`Formatting ${pendingCommands.length} file(s)`); + break; + case 'check': + info(`Checking format of ${pendingCommands.length} file(s)`); + break; + default: + throw Error(`Invalid format action "${action}": allowed actions are "format" and "check"`); + } + // The progress bar instance to use for progress tracking. + const progressBar = new cliProgress.Bar({ format: `[{bar}] ETA: {eta}s | {value}/{total} files`, clearOnComplete: true }); + // A local copy of the files to run the command on. + // An array to represent the current usage state of each of the threads for parallelization. + const threads = new Array(AVAILABLE_THREADS).fill(false); + // Recursively run the command on the next available file from the list using the provided + // thread. + function runCommandInThread(thread) { + const nextCommand = pendingCommands.pop(); + // If no file was pulled from the array, return as there are no more files to run against. + if (nextCommand === undefined) { + threads[thread] = false; + return; + } + // Get the file and formatter for the next command. + const { file, formatter } = nextCommand; + shelljs.exec(`${formatter.commandFor(action)} ${file}`, { async: true, silent: true }, (code, stdout, stderr) => { + // Run the provided callback function. + const failed = formatter.callbackFor(action)(file, code, stdout, stderr); + if (failed) { + failures.push(file); + } + // Note in the progress bar another file being completed. + progressBar.increment(1); + // If more files exist in the list, run again to work on the next file, + // using the same slot. + if (pendingCommands.length) { + return runCommandInThread(thread); + } + // If not more files are available, mark the thread as unused. + threads[thread] = false; + // If all of the threads are false, as they are unused, mark the progress bar + // completed and resolve the promise. + if (threads.every(active => !active)) { + progressBar.stop(); + resolve(failures); + } + }); + // Mark the thread as in use as the command execution has been started. + threads[thread] = true; + } + // Start the progress bar + progressBar.start(pendingCommands.length, 0); + // Start running the command on files from the least in each available thread. + threads.forEach((_, idx) => runCommandInThread(idx)); + }); +} + +/** + * @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 + */ +/** + * Format provided files in place. + */ +function formatFiles(files) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // Whether any files failed to format. + let failures = yield runFormatterInParallel(files, 'format'); + if (failures === false) { + info('No files matched for formatting.'); + process.exit(0); + } + // The process should exit as a failure if any of the files failed to format. + if (failures.length !== 0) { + error(`Formatting failed, see errors above for more information.`); + process.exit(1); + } + info(`√ Formatting complete.`); + process.exit(0); + }); +} +/** + * Check provided files for formatting correctness. + */ +function checkFiles(files) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // Files which are currently not formatted correctly. + const failures = yield runFormatterInParallel(files, 'check'); + if (failures === false) { + info('No files matched for formatting check.'); + process.exit(0); + } + if (failures.length) { + // Provide output expressing which files are failing formatting. + info.group('\nThe following files are out of format:'); + for (const file of failures) { + info(` - ${file}`); + } + info.groupEnd(); + info(); + // If the command is run in a non-CI environment, prompt to format the files immediately. + let runFormatter = false; + if (!process.env['CI']) { + runFormatter = yield promptConfirm('Format the files now?', true); + } + if (runFormatter) { + // Format the failing files as requested. + yield formatFiles(failures); + process.exit(0); + } + else { + // Inform user how to format files in the future. + info(); + info(`To format the failing file run the following command:`); + info(` yarn ng-dev format files ${failures.join(' ')}`); + process.exit(1); + } + } + else { + info('√ All files correctly formatted.'); + process.exit(0); + } + }); +} + +/** Build the parser for the format commands. */ +function buildFormatParser(localYargs) { + return localYargs.help() + .strict() + .demandCommand() + .option('check', { + type: 'boolean', + default: process.env['CI'] ? true : false, + description: 'Run the formatter to check formatting rather than updating code format' + }) + .command('all', 'Run the formatter on all files in the repository', args => args, ({ check }) => { + const executionCmd = check ? checkFiles : formatFiles; + executionCmd(allFiles()); + }) + .command('changed [shaOrRef]', 'Run the formatter on files changed since the provided sha/ref', args => args.positional('shaOrRef', { type: 'string' }), ({ shaOrRef, check }) => { + const sha = shaOrRef || 'master'; + const executionCmd = check ? checkFiles : formatFiles; + executionCmd(allChangedFilesSince(sha)); + }) + .command('staged', 'Run the formatter on all staged files', args => args, ({ check }) => { + const executionCmd = check ? checkFiles : formatFiles; + executionCmd(allStagedFiles()); + }) + .command('files ', 'Run the formatter on provided files', args => args.positional('files', { array: true, type: 'string' }), ({ check, files }) => { + const executionCmd = check ? checkFiles : formatFiles; + executionCmd(files); + }); +} + +/** + * @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 verify() { + /** Full path to NgBot config file */ + const NGBOT_CONFIG_YAML_PATH = path.resolve(getRepoBaseDir(), '.github/angular-robot.yml'); + /** The NgBot config file */ + const ngBotYaml = fs.readFileSync(NGBOT_CONFIG_YAML_PATH, 'utf8'); + try { + // Try parsing the config file to verify that the syntax is correct. + yaml.parse(ngBotYaml); + info(`${green('√')} Valid NgBot YAML config`); + } + catch (e) { + error(`${red('!')} Invalid NgBot YAML config`); + error(e); + process.exitCode = 1; + } +} + +/** Build the parser for the NgBot commands. */ +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 + */ +/** Get a PR from github */ +function getPr(prSchema, prNumber, git) { + return tslib.__awaiter(this, void 0, void 0, function () { + var _a, owner, name, PR_QUERY, result; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: + _a = git.remoteConfig, owner = _a.owner, name = _a.name; + PR_QUERY = typedGraphqlify.params({ + $number: 'Int!', + $owner: 'String!', + $name: 'String!', + }, { + repository: typedGraphqlify.params({ owner: '$owner', name: '$name' }, { + pullRequest: typedGraphqlify.params({ number: '$number' }, prSchema), + }) + }); + return [4 /*yield*/, git.github.graphql.query(PR_QUERY, { number: prNumber, owner: owner, name: name })]; + case 1: + result = (_b.sent()); + return [2 /*return*/, result.repository.pullRequest]; + } + }); + }); +} +/** Get all pending PRs from github */ +function getPendingPrs(prSchema, git) { + return tslib.__awaiter(this, void 0, void 0, function () { + var _a, owner, name, PRS_QUERY, cursor, hasNextPage, prs, params_1, results; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: + _a = git.remoteConfig, owner = _a.owner, name = _a.name; + PRS_QUERY = typedGraphqlify.params({ + $first: 'Int', + $after: 'String', + $owner: 'String!', + $name: 'String!', + }, { + repository: typedGraphqlify.params({ owner: '$owner', name: '$name' }, { + pullRequests: typedGraphqlify.params({ + first: '$first', + after: '$after', + states: "OPEN", + }, { + nodes: [prSchema], + pageInfo: { + hasNextPage: typedGraphqlify.types.boolean, + endCursor: typedGraphqlify.types.string, + }, + }), + }) + }); + hasNextPage = true; + prs = []; + _b.label = 1; + case 1: + if (!hasNextPage) return [3 /*break*/, 3]; + params_1 = { + after: cursor || null, + first: 100, + owner: owner, + name: name, + }; + return [4 /*yield*/, git.github.graphql.query(PRS_QUERY, params_1)]; + case 2: + results = _b.sent(); + prs.push.apply(prs, tslib.__spread(results.repository.pullRequests.nodes)); + hasNextPage = results.repository.pullRequests.pageInfo.hasNextPage; + cursor = results.repository.pullRequests.pageInfo.endCursor; + return [3 /*break*/, 1]; + case 3: return [2 /*return*/, prs]; + } + }); + }); +} + +/** + * @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 + */ +/* GraphQL schema for the response body for a pending PR. */ +const PR_SCHEMA = { + state: typedGraphqlify.types.string, + maintainerCanModify: typedGraphqlify.types.boolean, + viewerDidAuthor: typedGraphqlify.types.boolean, + headRefOid: typedGraphqlify.types.string, + headRef: { + name: typedGraphqlify.types.string, + repository: { + url: typedGraphqlify.types.string, + nameWithOwner: typedGraphqlify.types.string, + }, + }, + baseRef: { + name: typedGraphqlify.types.string, + repository: { + url: typedGraphqlify.types.string, + nameWithOwner: typedGraphqlify.types.string, + }, + }, +}; +class UnexpectedLocalChangesError extends Error { + constructor(m) { + super(m); + Object.setPrototypeOf(this, UnexpectedLocalChangesError.prototype); + } +} +class MaintainerModifyAccessError extends Error { + constructor(m) { + super(m); + Object.setPrototypeOf(this, MaintainerModifyAccessError.prototype); + } +} +/** + * Rebase the provided PR onto its merge target branch, and push up the resulting + * commit to the PRs repository. + */ +function checkOutPullRequestLocally(prNumber, githubToken, opts = {}) { + return tslib.__awaiter(this, void 0, void 0, function* () { + /** Authenticated Git client for git and Github interactions. */ + const git = new GitClient(githubToken); + // In order to preserve local changes, checkouts cannot occur if local changes are present in the + // git environment. Checked before retrieving the PR to fail fast. + if (git.hasLocalChanges()) { + throw new UnexpectedLocalChangesError('Unable to checkout PR due to uncommitted changes.'); + } + /** + * The branch or revision originally checked out before this method performed + * any Git operations that may change the working branch. + */ + const previousBranchOrRevision = git.getCurrentBranchOrRevision(); + /* The PR information from Github. */ + const pr = yield getPr(PR_SCHEMA, prNumber, git); + /** The branch name of the PR from the repository the PR came from. */ + const headRefName = pr.headRef.name; + /** The full ref for the repository and branch the PR came from. */ + const fullHeadRef = `${pr.headRef.repository.nameWithOwner}:${headRefName}`; + /** The full URL path of the repository the PR came from with github token as authentication. */ + const headRefUrl = addTokenToGitHttpsUrl(pr.headRef.repository.url, githubToken); + // Note: Since we use a detached head for rebasing the PR and therefore do not have + // remote-tracking branches configured, we need to set our expected ref and SHA. This + // allows us to use `--force-with-lease` for the detached head while ensuring that we + // never accidentally override upstream changes that have been pushed in the meanwhile. + // See: + // https://git-scm.com/docs/git-push#Documentation/git-push.txt---force-with-leaseltrefnamegtltexpectgt + /** Flag for a force push with leage back to upstream. */ + const forceWithLeaseFlag = `--force-with-lease=${headRefName}:${pr.headRefOid}`; + // If the PR does not allow maintainers to modify it, exit as the rebased PR cannot + // be pushed up. + if (!pr.maintainerCanModify && !pr.viewerDidAuthor && !opts.allowIfMaintainerCannotModify) { + throw new MaintainerModifyAccessError('PR is not set to allow maintainers to modify the PR'); + } + try { + // Fetch the branch at the commit of the PR, and check it out in a detached state. + info(`Checking out PR #${prNumber} from ${fullHeadRef}`); + git.run(['fetch', headRefUrl, headRefName]); + git.run(['checkout', '--detach', 'FETCH_HEAD']); + } + catch (e) { + git.checkout(previousBranchOrRevision, true); + throw e; + } + return { + /** + * Pushes the current local branch to the PR on the upstream repository. + * + * @returns true If the command did not fail causing a GitCommandError to be thrown. + * @throws GitCommandError Thrown when the push back to upstream fails. + */ + pushToUpstream: () => { + git.run(['push', headRefUrl, `HEAD:${headRefName}`, forceWithLeaseFlag]); + return true; + }, + /** Restores the state of the local repository to before the PR checkout occured. */ + resetGitState: () => { + return git.checkout(previousBranchOrRevision, true); + } + }; + }); +} + +/** + * @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 checkout pull request command. */ +function builder$5(yargs) { + return addGithubTokenOption(yargs).positional('prNumber', { type: 'number', demandOption: true }); +} +/** Handles the checkout pull request command. */ +function handler$5({ prNumber, githubToken }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const prCheckoutOptions = { allowIfMaintainerCannotModify: true, branchName: `pr-${prNumber}` }; + yield checkOutPullRequestLocally(prNumber, githubToken, prCheckoutOptions); + }); +} +/** yargs command module for checking out a PR */ +const CheckoutCommandModule = { + handler: handler$5, + builder: builder$5, + command: 'checkout ', + describe: 'Checkout a PR from the upstream repo', +}; + +/** + * @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 + */ +/* GraphQL schema for the response body for each pending PR. */ +const PR_SCHEMA$1 = { + headRef: { + name: typedGraphqlify.types.string, + repository: { + url: typedGraphqlify.types.string, + nameWithOwner: typedGraphqlify.types.string, + }, + }, + baseRef: { + name: typedGraphqlify.types.string, + repository: { + url: typedGraphqlify.types.string, + nameWithOwner: typedGraphqlify.types.string, + }, + }, + updatedAt: typedGraphqlify.types.string, + number: typedGraphqlify.types.number, + mergeable: typedGraphqlify.types.string, + title: typedGraphqlify.types.string, +}; +/** Convert raw Pull Request response from Github to usable Pull Request object. */ +function processPr(pr) { + return Object.assign(Object.assign({}, pr), { updatedAt: (new Date(pr.updatedAt)).getTime() }); +} +/** 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. */ +function discoverNewConflictsForPr(newPrNumber, updatedAfter, config = getConfig()) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const git = new GitClient(); + // If there are any local changes in the current repository state, the + // check cannot run as it needs to move between branches. + if (git.hasLocalChanges()) { + error('Cannot run with local changes. Please make sure there are no local changes.'); + process.exit(1); + } + /** The active github branch or revision before we performed any Git commands. */ + const previousBranchOrRevision = git.getCurrentBranchOrRevision(); + /* Progress bar to indicate progress. */ + const progressBar = new cliProgress.Bar({ format: `[{bar}] ETA: {eta}s | {value}/{total}` }); + /* PRs which were found to be conflicting. */ + const conflicts = []; + info(`Requesting pending PRs from Github`); + /** List of PRs from github currently known as mergable. */ + const allPendingPRs = (yield getPendingPrs(PR_SCHEMA$1, git)).map(processPr); + /** The PR which is being checked against. */ + const requestedPr = allPendingPRs.find(pr => pr.number === newPrNumber); + if (requestedPr === undefined) { + error(`The request PR, #${newPrNumber} was not found as a pending PR on github, please confirm`); + 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); + }); + info(`Retrieved ${allPendingPRs.length} total pending PRs`); + 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) { + error('The requested PR currently has conflicts'); + cleanUpGitState(previousBranchOrRevision); + 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(); + info(); + info(`Result:`); + cleanUpGitState(previousBranchOrRevision); + // If no conflicts are found, exit successfully. + if (conflicts.length === 0) { + info(`No new conflicting PRs found after #${newPrNumber} merging`); + process.exit(0); + } + // Inform about discovered conflicts, exit with failure. + error.group(`${conflicts.length} PR(s) which conflict(s) after #${newPrNumber} merges:`); + for (const pr of conflicts) { + error(` - #${pr.number}: ${pr.title}`); + } + error.groupEnd(); + process.exit(1); + }); +} +/** Reset git back to the provided branch or revision. */ +function cleanUpGitState(previousBranchOrRevision) { + // 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 ${previousBranchOrRevision}`); + // Delete the generated branch. + exec(`git branch -D ${tempWorkingBranch}`); +} + +/** + * @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 discover-new-conflicts pull request command. */ +function buildDiscoverNewConflictsCommand(yargs) { + return yargs + .option('date', { + description: 'Only consider PRs updated since provided date', + defaultDescription: '30 days ago', + coerce: (date) => typeof date === 'number' ? date : Date.parse(date), + default: getThirtyDaysAgoDate(), + }) + .positional('pr-number', { demandOption: true, type: 'number' }); +} +/** Handles the discover-new-conflicts pull request command. */ +function handleDiscoverNewConflictsCommand({ 'pr-number': prNumber, date }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // If a provided date is not able to be parsed, yargs provides it as NaN. + if (isNaN(date)) { + error('Unable to parse the value provided via --date flag'); + process.exit(1); + } + yield discoverNewConflictsForPr(prNumber, date); + }); +} +/** Gets a date object 30 days ago from today. */ +function getThirtyDaysAgoDate() { + 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.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. + * + * 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 + */ +/** + * Class that can be used to describe pull request failures. A failure + * is described through a human-readable message and a flag indicating + * whether it is non-fatal or not. + */ +var PullRequestFailure = /** @class */ (function () { + function PullRequestFailure( + /** Human-readable message for the failure */ + message, + /** Whether the failure is non-fatal and can be forcibly ignored. */ + nonFatal) { + if (nonFatal === void 0) { nonFatal = false; } + this.message = message; + this.nonFatal = nonFatal; + } + PullRequestFailure.claUnsigned = function () { + return new this("CLA has not been signed. Please make sure the PR author has signed the CLA."); + }; + PullRequestFailure.failingCiJobs = function () { + return new this("Failing CI jobs.", true); + }; + PullRequestFailure.pendingCiJobs = function () { + return new this("Pending CI jobs.", true); + }; + PullRequestFailure.notMergeReady = function () { + return new this("Not marked as merge ready."); + }; + PullRequestFailure.noTargetLabel = function () { + return new this("No target branch could be determined. Please ensure a target label is set."); + }; + PullRequestFailure.mismatchingTargetBranch = function (allowedBranches) { + return new this("Pull request is set to wrong base branch. Please update the PR in the Github UI " + + ("to one of the following branches: " + allowedBranches.join(', ') + ".")); + }; + PullRequestFailure.unsatisfiedBaseSha = function () { + return new this("Pull request has not been rebased recently and could be bypassing CI checks. " + + "Please rebase the PR."); + }; + PullRequestFailure.mergeConflicts = function (failedBranches) { + return new this("Could not merge pull request into the following branches due to merge " + + ("conflicts: " + failedBranches.join(', ') + ". Please rebase the PR or update the target label.")); + }; + PullRequestFailure.unknownMergeError = function () { + return new this("Unknown merge error occurred. Please see console output above for debugging."); + }; + PullRequestFailure.unableToFixupCommitMessageSquashOnly = function () { + return new this("Unable to fixup commit message of pull request. Commit message can only be " + + "modified if the PR is merged using squash."); + }; + PullRequestFailure.notFound = function () { + return new this("Pull request could not be found upstream."); + }; + PullRequestFailure.insufficientPermissionsToMerge = function (message) { + if (message === void 0) { message = "Insufficient Github API permissions to merge pull request. Please ensure that " + + "your auth token has write access."; } + return new this(message); + }; + return PullRequestFailure; +}()); + +/** + * @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 getCaretakerNotePromptMessage(pullRequest) { + return red('Pull request has a caretaker note applied. Please make sure you read it.') + + ("\nQuick link to PR: " + pullRequest.url + "\nDo you want to proceed merging?"); +} +function getTargettedBranchesConfirmationPromptMessage(pullRequest) { + var targetBranchListAsString = pullRequest.targetBranches.map(function (b) { return " - " + b + "\n"; }).join(''); + 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. + * + * 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 specified pull request against the given configuration. + * If the pull requests fails, a pull request failure is returned. + */ +function loadAndValidatePullRequest(_a, prNumber, ignoreNonFatalFailures) { + var git = _a.git, config = _a.config; + if (ignoreNonFatalFailures === void 0) { ignoreNonFatalFailures = false; } + return tslib.__awaiter(this, void 0, void 0, function () { + var prData, labels, targetLabel, state, githubTargetBranch, requiredBaseSha, needsCommitMessageFixup, hasCaretakerNote, targetBranches, error_1; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, fetchPullRequestFromGithub(git, prNumber)]; + case 1: + prData = _b.sent(); + if (prData === null) { + return [2 /*return*/, PullRequestFailure.notFound()]; + } + labels = prData.labels.map(function (l) { return l.name; }); + if (!labels.some(function (name) { return matchesPattern(name, config.mergeReadyLabel); })) { + return [2 /*return*/, PullRequestFailure.notMergeReady()]; + } + if (!labels.some(function (name) { return matchesPattern(name, config.claSignedLabel); })) { + return [2 /*return*/, PullRequestFailure.claUnsigned()]; + } + targetLabel = getTargetLabelFromPullRequest(config, labels); + if (targetLabel === null) { + return [2 /*return*/, PullRequestFailure.noTargetLabel()]; + } + return [4 /*yield*/, git.github.repos.getCombinedStatusForRef(tslib.__assign(tslib.__assign({}, git.remoteParams), { ref: prData.head.sha }))]; + case 2: + state = (_b.sent()).data.state; + if (state === 'failure' && !ignoreNonFatalFailures) { + return [2 /*return*/, PullRequestFailure.failingCiJobs()]; + } + if (state === 'pending' && !ignoreNonFatalFailures) { + return [2 /*return*/, PullRequestFailure.pendingCiJobs()]; + } + githubTargetBranch = prData.base.ref; + requiredBaseSha = config.requiredBaseCommits && config.requiredBaseCommits[githubTargetBranch]; + needsCommitMessageFixup = !!config.commitMessageFixupLabel && + labels.some(function (name) { return matchesPattern(name, config.commitMessageFixupLabel); }); + hasCaretakerNote = !!config.caretakerNoteLabel && + labels.some(function (name) { return matchesPattern(name, config.caretakerNoteLabel); }); + _b.label = 3; + case 3: + _b.trys.push([3, 5, , 6]); + return [4 /*yield*/, getBranchesFromTargetLabel(targetLabel, githubTargetBranch)]; + case 4: + targetBranches = _b.sent(); + return [3 /*break*/, 6]; + case 5: + error_1 = _b.sent(); + if (error_1 instanceof InvalidTargetBranchError || error_1 instanceof InvalidTargetLabelError) { + return [2 /*return*/, new PullRequestFailure(error_1.failureMessage)]; + } + throw error_1; + case 6: return [2 /*return*/, { + url: prData.html_url, + prNumber: prNumber, + labels: labels, + requiredBaseSha: requiredBaseSha, + githubTargetBranch: githubTargetBranch, + needsCommitMessageFixup: needsCommitMessageFixup, + hasCaretakerNote: hasCaretakerNote, + targetBranches: targetBranches, + title: prData.title, + commitCount: prData.commits, + }]; + } + }); + }); +} +/** Fetches a pull request from Github. Returns null if an error occurred. */ +function fetchPullRequestFromGithub(git, prNumber) { + return tslib.__awaiter(this, void 0, void 0, function () { + var result, e_1; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + _a.trys.push([0, 2, , 3]); + return [4 /*yield*/, git.github.pulls.get(tslib.__assign(tslib.__assign({}, git.remoteParams), { pull_number: prNumber }))]; + case 1: + result = _a.sent(); + return [2 /*return*/, result.data]; + case 2: + e_1 = _a.sent(); + // If the pull request could not be found, we want to return `null` so + // that the error can be handled gracefully. + if (e_1.status === 404) { + return [2 /*return*/, null]; + } + throw e_1; + case 3: return [2 /*return*/]; + } + }); + }); +} +/** Whether the specified value resolves to a pull request. */ +function isPullRequest(v) { + return v.targetBranches !== undefined; +} + +/** + * @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 + */ +/** + * Name of a temporary branch that contains the head of a currently-processed PR. Note + * that a branch name should be used that most likely does not conflict with other local + * development branches. + */ +var TEMP_PR_HEAD_BRANCH = 'merge_pr_head'; +/** + * Base class for merge strategies. A merge strategy accepts a pull request and + * merges it into the determined target branches. + */ +var MergeStrategy = /** @class */ (function () { + function MergeStrategy(git) { + this.git = git; + } + /** + * Prepares a merge of the given pull request. The strategy by default will + * fetch all target branches and the pull request into local temporary branches. + */ + MergeStrategy.prototype.prepare = function (pullRequest) { + return tslib.__awaiter(this, void 0, void 0, function () { + return tslib.__generator(this, function (_a) { + this.fetchTargetBranches(pullRequest.targetBranches, "pull/" + pullRequest.prNumber + "/head:" + TEMP_PR_HEAD_BRANCH); + return [2 /*return*/]; + }); + }); + }; + /** Cleans up the pull request merge. e.g. deleting temporary local branches. */ + MergeStrategy.prototype.cleanup = function (pullRequest) { + return tslib.__awaiter(this, void 0, void 0, function () { + var _this = this; + return tslib.__generator(this, function (_a) { + // Delete all temporary target branches. + pullRequest.targetBranches.forEach(function (branchName) { return _this.git.run(['branch', '-D', _this.getLocalTargetBranchName(branchName)]); }); + // Delete temporary branch for the pull request head. + this.git.run(['branch', '-D', TEMP_PR_HEAD_BRANCH]); + return [2 /*return*/]; + }); + }); + }; + /** Gets the revision range for all commits in the given pull request. */ + MergeStrategy.prototype.getPullRequestRevisionRange = function (pullRequest) { + return this.getPullRequestBaseRevision(pullRequest) + ".." + TEMP_PR_HEAD_BRANCH; + }; + /** Gets the base revision of a pull request. i.e. the commit the PR is based on. */ + MergeStrategy.prototype.getPullRequestBaseRevision = function (pullRequest) { + return TEMP_PR_HEAD_BRANCH + "~" + pullRequest.commitCount; + }; + /** Gets a deterministic local branch name for a given branch. */ + MergeStrategy.prototype.getLocalTargetBranchName = function (targetBranch) { + return "merge_pr_target_" + targetBranch.replace(/\//g, '_'); + }; + /** + * Cherry-picks the given revision range into the specified target branches. + * @returns A list of branches for which the revisions could not be cherry-picked into. + */ + MergeStrategy.prototype.cherryPickIntoTargetBranches = function (revisionRange, targetBranches, options) { + var e_1, _a; + if (options === void 0) { options = {}; } + var cherryPickArgs = [revisionRange]; + var failedBranches = []; + if (options.dryRun) { + // https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt---no-commit + // This causes `git cherry-pick` to not generate any commits. Instead, the changes are + // applied directly in the working tree. This allow us to easily discard the changes + // for dry-run purposes. + cherryPickArgs.push('--no-commit'); + } + if (options.linkToOriginalCommits) { + // We add `-x` when cherry-picking as that will allow us to easily jump to original + // commits for cherry-picked commits. With that flag set, Git will automatically append + // the original SHA/revision to the commit message. e.g. `(cherry picked from commit <..>)`. + // https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt--x. + cherryPickArgs.push('-x'); + } + try { + // Cherry-pick the refspec into all determined target branches. + for (var targetBranches_1 = tslib.__values(targetBranches), targetBranches_1_1 = targetBranches_1.next(); !targetBranches_1_1.done; targetBranches_1_1 = targetBranches_1.next()) { + var branchName = targetBranches_1_1.value; + var localTargetBranch = this.getLocalTargetBranchName(branchName); + // Checkout the local target branch. + this.git.run(['checkout', localTargetBranch]); + // Cherry-pick the refspec into the target branch. + if (this.git.runGraceful(tslib.__spread(['cherry-pick'], cherryPickArgs)).status !== 0) { + // Abort the failed cherry-pick. We do this because Git persists the failed + // cherry-pick state globally in the repository. This could prevent future + // pull request merges as a Git thinks a cherry-pick is still in progress. + this.git.runGraceful(['cherry-pick', '--abort']); + failedBranches.push(branchName); + } + // If we run with dry run mode, we reset the local target branch so that all dry-run + // cherry-pick changes are discard. Changes are applied to the working tree and index. + if (options.dryRun) { + this.git.run(['reset', '--hard', 'HEAD']); + } + } + } + catch (e_1_1) { e_1 = { error: e_1_1 }; } + finally { + try { + if (targetBranches_1_1 && !targetBranches_1_1.done && (_a = targetBranches_1.return)) _a.call(targetBranches_1); + } + finally { if (e_1) throw e_1.error; } + } + return failedBranches; + }; + /** + * Fetches the given target branches. Also accepts a list of additional refspecs that + * should be fetched. This is helpful as multiple slow fetches could be avoided. + */ + MergeStrategy.prototype.fetchTargetBranches = function (names) { + var _this = this; + var extraRefspecs = []; + for (var _i = 1; _i < arguments.length; _i++) { + extraRefspecs[_i - 1] = arguments[_i]; + } + var fetchRefspecs = names.map(function (targetBranch) { + var localTargetBranch = _this.getLocalTargetBranchName(targetBranch); + return "refs/heads/" + targetBranch + ":" + localTargetBranch; + }); + // Fetch all target branches with a single command. We don't want to fetch them + // individually as that could cause an unnecessary slow-down. + this.git.run(tslib.__spread(['fetch', '-f', this.git.repoGitUrl], fetchRefspecs, extraRefspecs)); + }; + /** Pushes the given target branches upstream. */ + MergeStrategy.prototype.pushTargetBranchesUpstream = function (names) { + var _this = this; + var pushRefspecs = names.map(function (targetBranch) { + var localTargetBranch = _this.getLocalTargetBranchName(targetBranch); + return localTargetBranch + ":refs/heads/" + targetBranch; + }); + // Push all target branches with a single command if we don't run in dry-run mode. + // We don't want to push them individually as that could cause an unnecessary slow-down. + this.git.run(tslib.__spread(['push', this.git.repoGitUrl], pushRefspecs)); + }; + return MergeStrategy; +}()); + +/** + * @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 + */ +/** Separator between commit message header and body. */ +var COMMIT_HEADER_SEPARATOR = '\n\n'; +/** + * Merge strategy that primarily leverages the Github API. The strategy merges a given + * pull request into a target branch using the API. This ensures that Github displays + * the pull request as merged. The merged commits are then cherry-picked into the remaining + * target branches using the local Git instance. The benefit is that the Github merged state + * is properly set, but a notable downside is that PRs cannot use fixup or squash commits. + */ +var GithubApiMergeStrategy = /** @class */ (function (_super) { + tslib.__extends(GithubApiMergeStrategy, _super); + function GithubApiMergeStrategy(git, _config) { + var _this = _super.call(this, git) || this; + _this._config = _config; + return _this; + } + GithubApiMergeStrategy.prototype.merge = function (pullRequest) { + return tslib.__awaiter(this, void 0, void 0, function () { + var githubTargetBranch, prNumber, targetBranches, requiredBaseSha, needsCommitMessageFixup, method, cherryPickTargetBranches, failure, mergeOptions, mergeStatusCode, targetSha, result, e_1, targetCommitsCount, failedBranches; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + githubTargetBranch = pullRequest.githubTargetBranch, prNumber = pullRequest.prNumber, targetBranches = pullRequest.targetBranches, requiredBaseSha = pullRequest.requiredBaseSha, needsCommitMessageFixup = pullRequest.needsCommitMessageFixup; + // If the pull request does not have its base branch set to any determined target + // branch, we cannot merge using the API. + if (targetBranches.every(function (t) { return t !== githubTargetBranch; })) { + return [2 /*return*/, PullRequestFailure.mismatchingTargetBranch(targetBranches)]; + } + // In cases where a required base commit is specified for this pull request, check if + // the pull request contains the given commit. If not, return a pull request failure. + // This check is useful for enforcing that PRs are rebased on top of a given commit. + // e.g. a commit that changes the code ownership validation. PRs which are not rebased + // could bypass new codeowner ship rules. + if (requiredBaseSha && !this.git.hasCommit(TEMP_PR_HEAD_BRANCH, requiredBaseSha)) { + return [2 /*return*/, PullRequestFailure.unsatisfiedBaseSha()]; + } + method = this._getMergeActionFromPullRequest(pullRequest); + cherryPickTargetBranches = targetBranches.filter(function (b) { return b !== githubTargetBranch; }); + return [4 /*yield*/, this._checkMergability(pullRequest, cherryPickTargetBranches)]; + case 1: + failure = _a.sent(); + // If the PR could not be cherry-picked into all target branches locally, we know it can't + // be done through the Github API either. We abort merging and pass-through the failure. + if (failure !== null) { + return [2 /*return*/, failure]; + } + mergeOptions = tslib.__assign({ pull_number: prNumber, merge_method: method }, this.git.remoteParams); + if (!needsCommitMessageFixup) return [3 /*break*/, 3]; + // Commit message fixup does not work with other merge methods as the Github API only + // allows commit message modifications for squash merging. + if (method !== 'squash') { + return [2 /*return*/, PullRequestFailure.unableToFixupCommitMessageSquashOnly()]; + } + return [4 /*yield*/, this._promptCommitMessageEdit(pullRequest, mergeOptions)]; + case 2: + _a.sent(); + _a.label = 3; + case 3: + _a.trys.push([3, 5, , 6]); + return [4 /*yield*/, this.git.github.pulls.merge(mergeOptions)]; + case 4: + result = _a.sent(); + mergeStatusCode = result.status; + targetSha = result.data.sha; + return [3 /*break*/, 6]; + case 5: + e_1 = _a.sent(); + // Note: Github usually returns `404` as status code if the API request uses a + // token with insufficient permissions. Github does this because it doesn't want + // to leak whether a repository exists or not. In our case we expect a certain + // repository to exist, so we always treat this as a permission failure. + if (e_1.status === 403 || e_1.status === 404) { + return [2 /*return*/, PullRequestFailure.insufficientPermissionsToMerge()]; + } + throw e_1; + case 6: + // https://developer.github.com/v3/pulls/#response-if-merge-cannot-be-performed + // Pull request cannot be merged due to merge conflicts. + if (mergeStatusCode === 405) { + return [2 /*return*/, PullRequestFailure.mergeConflicts([githubTargetBranch])]; + } + if (mergeStatusCode !== 200) { + return [2 /*return*/, PullRequestFailure.unknownMergeError()]; + } + // If the PR does not need to be merged into any other target branches, + // we exit here as we already completed the merge. + if (!cherryPickTargetBranches.length) { + return [2 /*return*/, null]; + } + // Refresh the target branch the PR has been merged into through the API. We need + // to re-fetch as otherwise we cannot cherry-pick the new commits into the remaining + // target branches. + this.fetchTargetBranches([githubTargetBranch]); + targetCommitsCount = method === 'squash' ? 1 : pullRequest.commitCount; + return [4 /*yield*/, this.cherryPickIntoTargetBranches(targetSha + "~" + targetCommitsCount + ".." + targetSha, cherryPickTargetBranches, { + // Commits that have been created by the Github API do not necessarily contain + // a reference to the source pull request (unless the squash strategy is used). + // To ensure that original commits can be found when a commit is viewed in a + // target branch, we add a link to the original commits when cherry-picking. + linkToOriginalCommits: true, + })]; + case 7: + failedBranches = _a.sent(); + // We already checked whether the PR can be cherry-picked into the target branches, + // but in case the cherry-pick somehow fails, we still handle the conflicts here. The + // commits created through the Github API could be different (i.e. through squash). + if (failedBranches.length) { + return [2 /*return*/, PullRequestFailure.mergeConflicts(failedBranches)]; + } + this.pushTargetBranchesUpstream(cherryPickTargetBranches); + return [2 /*return*/, null]; + } + }); + }); + }; + /** + * Prompts the user for the commit message changes. Unlike as in the autosquash merge + * strategy, we cannot start an interactive rebase because we merge using the Github API. + * The Github API only allows modifications to PR title and body for squash merges. + */ + GithubApiMergeStrategy.prototype._promptCommitMessageEdit = function (pullRequest, mergeOptions) { + return tslib.__awaiter(this, void 0, void 0, function () { + var commitMessage, result, _a, newTitle, newMessage; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, this._getDefaultSquashCommitMessage(pullRequest)]; + case 1: + commitMessage = _b.sent(); + return [4 /*yield*/, inquirer.prompt({ + type: 'editor', + name: 'result', + message: 'Please update the commit message', + default: commitMessage, + })]; + case 2: + result = (_b.sent()).result; + _a = tslib.__read(result.split(COMMIT_HEADER_SEPARATOR)), newTitle = _a[0], newMessage = _a.slice(1); + // Update the merge options so that the changes are reflected in there. + mergeOptions.commit_title = newTitle + " (#" + pullRequest.prNumber + ")"; + mergeOptions.commit_message = newMessage.join(COMMIT_HEADER_SEPARATOR); + return [2 /*return*/]; + } + }); + }); + }; + /** + * Gets a commit message for the given pull request. Github by default concatenates + * multiple commit messages if a PR is merged in squash mode. We try to replicate this + * behavior here so that we have a default commit message that can be fixed up. + */ + GithubApiMergeStrategy.prototype._getDefaultSquashCommitMessage = function (pullRequest) { + return tslib.__awaiter(this, void 0, void 0, function () { + var commits, messageBase, joinedMessages; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, this._getPullRequestCommitMessages(pullRequest)]; + case 1: + commits = (_a.sent()) + .map(function (message) { return ({ message: message, parsed: parseCommitMessage(message) }); }); + messageBase = "" + pullRequest.title + COMMIT_HEADER_SEPARATOR; + if (commits.length <= 1) { + return [2 /*return*/, "" + messageBase + commits[0].parsed.body]; + } + joinedMessages = commits.map(function (c) { return "* " + c.message; }).join(COMMIT_HEADER_SEPARATOR); + return [2 /*return*/, "" + messageBase + joinedMessages]; + } + }); + }); + }; + /** Gets all commit messages of commits in the pull request. */ + GithubApiMergeStrategy.prototype._getPullRequestCommitMessages = function (_a) { + var prNumber = _a.prNumber; + return tslib.__awaiter(this, void 0, void 0, function () { + var request, allCommits; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: + request = this.git.github.pulls.listCommits.endpoint.merge(tslib.__assign(tslib.__assign({}, this.git.remoteParams), { pull_number: prNumber })); + return [4 /*yield*/, this.git.github.paginate(request)]; + case 1: + allCommits = _b.sent(); + return [2 /*return*/, allCommits.map(function (_a) { + var commit = _a.commit; + return commit.message; + })]; + } + }); + }); + }; + /** + * Checks if given pull request could be merged into its target branches. + * @returns A pull request failure if it the PR could not be merged. + */ + GithubApiMergeStrategy.prototype._checkMergability = function (pullRequest, targetBranches) { + return tslib.__awaiter(this, void 0, void 0, function () { + var revisionRange, failedBranches; + return tslib.__generator(this, function (_a) { + revisionRange = this.getPullRequestRevisionRange(pullRequest); + failedBranches = this.cherryPickIntoTargetBranches(revisionRange, targetBranches, { dryRun: true }); + if (failedBranches.length) { + return [2 /*return*/, PullRequestFailure.mergeConflicts(failedBranches)]; + } + return [2 /*return*/, null]; + }); + }); + }; + /** Determines the merge action from the given pull request. */ + GithubApiMergeStrategy.prototype._getMergeActionFromPullRequest = function (_a) { + var labels = _a.labels; + if (this._config.labels) { + var matchingLabel = this._config.labels.find(function (_a) { + var pattern = _a.pattern; + return labels.some(function (l) { return matchesPattern(l, pattern); }); + }); + if (matchingLabel !== undefined) { + return matchingLabel.method; + } + } + return this._config.default; + }; + return GithubApiMergeStrategy; +}(MergeStrategy)); + +/** + * @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 + */ +/** Path to the commit message filter script. Git expects this paths to use forward slashes. */ +var MSG_FILTER_SCRIPT = path.join(__dirname, './commit-message-filter.js').replace(/\\/g, '/'); +/** + * Merge strategy that does not use the Github API for merging. Instead, it fetches + * all target branches and the PR locally. The PR is then cherry-picked with autosquash + * enabled into the target branches. The benefit is the support for fixup and squash commits. + * A notable downside though is that Github does not show the PR as `Merged` due to non + * fast-forward merges + */ +var AutosquashMergeStrategy = /** @class */ (function (_super) { + tslib.__extends(AutosquashMergeStrategy, _super); + function AutosquashMergeStrategy() { + return _super !== null && _super.apply(this, arguments) || this; + } + /** + * Merges the specified pull request into the target branches and pushes the target + * branches upstream. This method requires the temporary target branches to be fetched + * already as we don't want to fetch the target branches per pull request merge. This + * would causes unnecessary multiple fetch requests when multiple PRs are merged. + * @throws {GitCommandError} An unknown Git command error occurred that is not + * specific to the pull request merge. + * @returns A pull request failure or null in case of success. + */ + AutosquashMergeStrategy.prototype.merge = function (pullRequest) { + return tslib.__awaiter(this, void 0, void 0, function () { + var prNumber, targetBranches, requiredBaseSha, needsCommitMessageFixup, baseSha, revisionRange, branchOrRevisionBeforeRebase, rebaseEnv, failedBranches; + return tslib.__generator(this, function (_a) { + prNumber = pullRequest.prNumber, targetBranches = pullRequest.targetBranches, requiredBaseSha = pullRequest.requiredBaseSha, needsCommitMessageFixup = pullRequest.needsCommitMessageFixup; + // In case a required base is specified for this pull request, check if the pull + // request contains the given commit. If not, return a pull request failure. This + // check is useful for enforcing that PRs are rebased on top of a given commit. e.g. + // a commit that changes the codeowner ship validation. PRs which are not rebased + // could bypass new codeowner ship rules. + if (requiredBaseSha && !this.git.hasCommit(TEMP_PR_HEAD_BRANCH, requiredBaseSha)) { + return [2 /*return*/, PullRequestFailure.unsatisfiedBaseSha()]; + } + baseSha = this.git.run(['rev-parse', this.getPullRequestBaseRevision(pullRequest)]).stdout.trim(); + revisionRange = baseSha + ".." + TEMP_PR_HEAD_BRANCH; + branchOrRevisionBeforeRebase = this.git.getCurrentBranchOrRevision(); + rebaseEnv = needsCommitMessageFixup ? undefined : tslib.__assign(tslib.__assign({}, process.env), { GIT_SEQUENCE_EDITOR: 'true' }); + this.git.run(['rebase', '--interactive', '--autosquash', baseSha, TEMP_PR_HEAD_BRANCH], { stdio: 'inherit', env: rebaseEnv }); + // Update pull requests commits to reference the pull request. This matches what + // Github does when pull requests are merged through the Web UI. The motivation is + // that it should be easy to determine which pull request contained a given commit. + // Note: The filter-branch command relies on the working tree, so we want to make sure + // that we are on the initial branch or revision where the merge script has been invoked. + this.git.run(['checkout', '-f', branchOrRevisionBeforeRebase]); + this.git.run(['filter-branch', '-f', '--msg-filter', MSG_FILTER_SCRIPT + " " + prNumber, revisionRange]); + failedBranches = this.cherryPickIntoTargetBranches(revisionRange, targetBranches); + if (failedBranches.length) { + return [2 /*return*/, PullRequestFailure.mergeConflicts(failedBranches)]; + } + this.pushTargetBranchesUpstream(targetBranches); + return [2 /*return*/, null]; + }); + }); + }; + return AutosquashMergeStrategy; +}(MergeStrategy)); + +/** + * @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 + */ +/** + * Class that accepts a merge script configuration and Github token. It provides + * a programmatic interface for merging multiple pull requests based on their + * labels that have been resolved through the merge script configuration. + */ +var PullRequestMergeTask = /** @class */ (function () { + function PullRequestMergeTask(config, git) { + this.config = config; + this.git = git; + } + /** + * Merges the given pull request and pushes it upstream. + * @param prNumber Pull request that should be merged. + * @param force Whether non-critical pull request failures should be ignored. + */ + PullRequestMergeTask.prototype.merge = function (prNumber, force) { + if (force === void 0) { force = false; } + return tslib.__awaiter(this, void 0, void 0, function () { + var hasOauthScopes, pullRequest, _a, strategy, previousBranchOrRevision, failure, e_1; + var _this = this; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, this.git.hasOauthScopes(function (scopes, missing) { + if (!scopes.includes('repo')) { + if (_this.config.remote.private) { + missing.push('repo'); + } + else if (!scopes.includes('public_repo')) { + missing.push('public_repo'); + } + } + })]; + case 1: + hasOauthScopes = _b.sent(); + if (hasOauthScopes !== true) { + return [2 /*return*/, { + status: 5 /* GITHUB_ERROR */, + failure: PullRequestFailure.insufficientPermissionsToMerge(hasOauthScopes.error) + }]; + } + if (this.git.hasUncommittedChanges()) { + return [2 /*return*/, { status: 1 /* DIRTY_WORKING_DIR */ }]; + } + return [4 /*yield*/, loadAndValidatePullRequest(this, prNumber, force)]; + case 2: + pullRequest = _b.sent(); + if (!isPullRequest(pullRequest)) { + return [2 /*return*/, { status: 3 /* FAILED */, failure: pullRequest }]; + } + return [4 /*yield*/, promptConfirm(getTargettedBranchesConfirmationPromptMessage(pullRequest))]; + case 3: + if (!(_b.sent())) { + return [2 /*return*/, { status: 4 /* USER_ABORTED */ }]; + } + _a = pullRequest.hasCaretakerNote; + if (!_a) return [3 /*break*/, 5]; + return [4 /*yield*/, promptConfirm(getCaretakerNotePromptMessage(pullRequest))]; + case 4: + _a = !(_b.sent()); + _b.label = 5; + case 5: + // If the pull request has a caretaker note applied, raise awareness by prompting + // the caretaker. The caretaker can then decide to proceed or abort the merge. + if (_a) { + return [2 /*return*/, { status: 4 /* USER_ABORTED */ }]; + } + strategy = this.config.githubApiMerge ? + new GithubApiMergeStrategy(this.git, this.config.githubApiMerge) : + new AutosquashMergeStrategy(this.git); + previousBranchOrRevision = null; + _b.label = 6; + case 6: + _b.trys.push([6, 10, 11, 12]); + previousBranchOrRevision = this.git.getCurrentBranchOrRevision(); + // Run preparations for the merge (e.g. fetching branches). + return [4 /*yield*/, strategy.prepare(pullRequest)]; + case 7: + // Run preparations for the merge (e.g. fetching branches). + _b.sent(); + return [4 /*yield*/, strategy.merge(pullRequest)]; + case 8: + failure = _b.sent(); + if (failure !== null) { + return [2 /*return*/, { status: 3 /* FAILED */, failure: failure }]; + } + // Switch back to the previous branch. We need to do this before deleting the temporary + // branches because we cannot delete branches which are currently checked out. + this.git.run(['checkout', '-f', previousBranchOrRevision]); + return [4 /*yield*/, strategy.cleanup(pullRequest)]; + case 9: + _b.sent(); + // Return a successful merge status. + return [2 /*return*/, { status: 2 /* SUCCESS */ }]; + case 10: + e_1 = _b.sent(); + // Catch all git command errors and return a merge result w/ git error status code. + // Other unknown errors which aren't caused by a git command are re-thrown. + if (e_1 instanceof GitCommandError) { + return [2 /*return*/, { status: 0 /* UNKNOWN_GIT_ERROR */ }]; + } + throw e_1; + case 11: + // Always try to restore the branch if possible. We don't want to leave + // the repository in a different state than before. + if (previousBranchOrRevision !== null) { + this.git.runGraceful(['checkout', '-f', previousBranchOrRevision]); + } + return [7 /*endfinally*/]; + case 12: return [2 /*return*/]; + } + }); + }); + }; + return PullRequestMergeTask; +}()); + +/** + * @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 + */ +/** + * Merges a given pull request based on labels configured in the given merge configuration. + * Pull requests can be merged with different strategies such as the Github API merge + * strategy, or the local autosquash strategy. Either strategy has benefits and downsides. + * More information on these strategies can be found in their dedicated strategy classes. + * + * See {@link GithubApiMergeStrategy} and {@link AutosquashMergeStrategy} + * + * @param prNumber Number of the pull request that should be merged. + * @param githubToken Github token used for merging (i.e. fetching and pushing) + * @param projectRoot Path to the local Git project that is used for merging. + * @param config Configuration for merging pull requests. + */ +function mergePullRequest(prNumber, githubToken, projectRoot, config) { + if (projectRoot === void 0) { projectRoot = getRepoBaseDir(); } + return tslib.__awaiter(this, void 0, void 0, function () { + /** Performs the merge and returns whether it was successful or not. */ + function performMerge(ignoreFatalErrors) { + return tslib.__awaiter(this, void 0, void 0, function () { + var result, e_1; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + _a.trys.push([0, 3, , 4]); + return [4 /*yield*/, api.merge(prNumber, ignoreFatalErrors)]; + case 1: + result = _a.sent(); + return [4 /*yield*/, handleMergeResult(result, ignoreFatalErrors)]; + case 2: return [2 /*return*/, _a.sent()]; + case 3: + e_1 = _a.sent(); + // Catch errors to the Github API for invalid requests. We want to + // exit the script with a better explanation of the error. + if (e_1 instanceof GithubApiRequestError && e_1.status === 401) { + error(red('Github API request failed. ' + e_1.message)); + error(yellow('Please ensure that your provided token is valid.')); + error(yellow("You can generate a token here: " + GITHUB_TOKEN_GENERATE_URL)); + process.exit(1); + } + throw e_1; + case 4: return [2 /*return*/]; + } + }); + }); + } + /** + * Prompts whether the specified pull request should be forcibly merged. If so, merges + * the specified pull request forcibly (ignoring non-critical failures). + * @returns Whether the specified pull request has been forcibly merged. + */ + function promptAndPerformForceMerge() { + return tslib.__awaiter(this, void 0, void 0, function () { + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, promptConfirm('Do you want to forcibly proceed with merging?')]; + case 1: + if (_a.sent()) { + // Perform the merge in force mode. This means that non-fatal failures + // are ignored and the merge continues. + return [2 /*return*/, performMerge(true)]; + } + return [2 /*return*/, false]; + } + }); + }); + } + /** + * Handles the merge result by printing console messages, exiting the process + * based on the result, or by restarting the merge if force mode has been enabled. + * @returns Whether the merge completed without errors or not. + */ + function handleMergeResult(result, disableForceMergePrompt) { + if (disableForceMergePrompt === void 0) { disableForceMergePrompt = false; } + return tslib.__awaiter(this, void 0, void 0, function () { + var failure, status, canForciblyMerge, _a; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: + failure = result.failure, status = result.status; + canForciblyMerge = failure && failure.nonFatal; + _a = status; + switch (_a) { + case 2 /* SUCCESS */: return [3 /*break*/, 1]; + case 1 /* DIRTY_WORKING_DIR */: return [3 /*break*/, 2]; + case 0 /* UNKNOWN_GIT_ERROR */: return [3 /*break*/, 3]; + case 5 /* GITHUB_ERROR */: return [3 /*break*/, 4]; + case 4 /* USER_ABORTED */: return [3 /*break*/, 5]; + case 3 /* FAILED */: return [3 /*break*/, 6]; + } + return [3 /*break*/, 9]; + case 1: + info(green("Successfully merged the pull request: #" + prNumber)); + return [2 /*return*/, true]; + case 2: + error(red("Local working repository not clean. Please make sure there are " + + "no uncommitted changes.")); + return [2 /*return*/, false]; + case 3: + error(red('An unknown Git error has been thrown. Please check the output ' + + 'above for details.')); + return [2 /*return*/, false]; + case 4: + error(red('An error related to interacting with Github has been discovered.')); + error(failure.message); + return [2 /*return*/, false]; + case 5: + info("Merge of pull request has been aborted manually: #" + prNumber); + return [2 /*return*/, true]; + case 6: + error(yellow("Could not merge the specified pull request.")); + error(red(failure.message)); + if (!(canForciblyMerge && !disableForceMergePrompt)) return [3 /*break*/, 8]; + info(); + info(yellow('The pull request above failed due to non-critical errors.')); + info(yellow("This error can be forcibly ignored if desired.")); + return [4 /*yield*/, promptAndPerformForceMerge()]; + case 7: return [2 /*return*/, _b.sent()]; + case 8: return [2 /*return*/, false]; + case 9: throw Error("Unexpected merge result: " + status); + } + }); + }); + } + var api; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + // Set the environment variable to skip all git commit hooks triggered by husky. We are unable to + // rely on `--no-verify` as some hooks still run, notably the `prepare-commit-msg` hook. + process.env['HUSKY_SKIP_HOOKS'] = '1'; + return [4 /*yield*/, createPullRequestMergeTask(githubToken, projectRoot, config)]; + case 1: + api = _a.sent(); + return [4 /*yield*/, performMerge(false)]; + case 2: + // Perform the merge. Force mode can be activated through a command line flag. + // Alternatively, if the merge fails with non-fatal failures, the script + // will prompt whether it should rerun in force mode. + if (!(_a.sent())) { + process.exit(1); + } + return [2 /*return*/]; + } + }); + }); +} +/** + * Creates the pull request merge task from the given Github token, project root + * and optional explicit configuration. An explicit configuration can be specified + * when the merge script is used outside of a `ng-dev` configured repository. + */ +function createPullRequestMergeTask(githubToken, projectRoot, explicitConfig) { + return tslib.__awaiter(this, void 0, void 0, function () { + var git_1, devInfraConfig, git, _a, config, errors; + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: + if (explicitConfig !== undefined) { + git_1 = new GitClient(githubToken, { github: explicitConfig.remote }, projectRoot); + return [2 /*return*/, new PullRequestMergeTask(explicitConfig, git_1)]; + } + devInfraConfig = getConfig(); + git = new GitClient(githubToken, devInfraConfig, projectRoot); + return [4 /*yield*/, loadAndValidateConfig(devInfraConfig, git.github)]; + case 1: + _a = _b.sent(), config = _a.config, errors = _a.errors; + if (errors) { + error(red('Invalid merge configuration:')); + errors.forEach(function (desc) { return error(yellow(" - " + desc)); }); + process.exit(1); + } + // Set the remote so that the merge tool has access to information about + // the remote it intends to merge to. + config.remote = devInfraConfig.github; + // We can cast this to a merge config with remote because we always set the + // remote above. + return [2 /*return*/, new PullRequestMergeTask(config, git)]; + } + }); + }); +} + +/** + * @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 options for the merge command. */ +function buildMergeCommand(yargs) { + return addGithubTokenOption(yargs).help().strict().positional('pr-number', { demandOption: true, type: 'number' }); +} +/** Handles the merge command. i.e. performs the merge of a specified pull request. */ +function handleMergeCommand(_a) { + var pr = _a["pr-number"], githubToken = _a.githubToken; + return tslib.__awaiter(this, void 0, void 0, function () { + return tslib.__generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, mergePullRequest(pr, githubToken)]; + case 1: + _b.sent(); + return [2 /*return*/]; + } + }); + }); +} + +/** + * @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 + */ +/* GraphQL schema for the response body for each pending PR. */ +const PR_SCHEMA$2 = { + state: typedGraphqlify.types.string, + maintainerCanModify: typedGraphqlify.types.boolean, + viewerDidAuthor: typedGraphqlify.types.boolean, + headRefOid: typedGraphqlify.types.string, + headRef: { + name: typedGraphqlify.types.string, + repository: { + url: typedGraphqlify.types.string, + nameWithOwner: typedGraphqlify.types.string, + }, + }, + baseRef: { + name: typedGraphqlify.types.string, + repository: { + url: typedGraphqlify.types.string, + nameWithOwner: typedGraphqlify.types.string, + }, + }, +}; +/** + * Rebase the provided PR onto its merge target branch, and push up the resulting + * commit to the PRs repository. + */ +function rebasePr(prNumber, githubToken, config = getConfig()) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const git = new GitClient(githubToken); + // TODO: Rely on a common assertNoLocalChanges function. + if (git.hasLocalChanges()) { + error('Cannot perform rebase of PR with local changes.'); + process.exit(1); + } + /** + * The branch or revision originally checked out before this method performed + * any Git operations that may change the working branch. + */ + const previousBranchOrRevision = git.getCurrentBranchOrRevision(); + /* Get the PR information from Github. */ + const pr = yield getPr(PR_SCHEMA$2, prNumber, git); + const headRefName = pr.headRef.name; + const baseRefName = pr.baseRef.name; + const fullHeadRef = `${pr.headRef.repository.nameWithOwner}:${headRefName}`; + const fullBaseRef = `${pr.baseRef.repository.nameWithOwner}:${baseRefName}`; + const headRefUrl = addTokenToGitHttpsUrl(pr.headRef.repository.url, githubToken); + const baseRefUrl = addTokenToGitHttpsUrl(pr.baseRef.repository.url, githubToken); + // Note: Since we use a detached head for rebasing the PR and therefore do not have + // remote-tracking branches configured, we need to set our expected ref and SHA. This + // allows us to use `--force-with-lease` for the detached head while ensuring that we + // never accidentally override upstream changes that have been pushed in the meanwhile. + // See: + // https://git-scm.com/docs/git-push#Documentation/git-push.txt---force-with-leaseltrefnamegtltexpectgt + const forceWithLeaseFlag = `--force-with-lease=${headRefName}:${pr.headRefOid}`; + // If the PR does not allow maintainers to modify it, exit as the rebased PR cannot + // be pushed up. + if (!pr.maintainerCanModify && !pr.viewerDidAuthor) { + error(`Cannot rebase as you did not author the PR and the PR does not allow maintainers` + + `to modify the PR`); + process.exit(1); + } + try { + // Fetch the branch at the commit of the PR, and check it out in a detached state. + info(`Checking out PR #${prNumber} from ${fullHeadRef}`); + git.run(['fetch', headRefUrl, headRefName]); + git.run(['checkout', '--detach', 'FETCH_HEAD']); + // Fetch the PRs target branch and rebase onto it. + info(`Fetching ${fullBaseRef} to rebase #${prNumber} on`); + git.run(['fetch', baseRefUrl, baseRefName]); + info(`Attempting to rebase PR #${prNumber} on ${fullBaseRef}`); + const rebaseResult = git.runGraceful(['rebase', 'FETCH_HEAD']); + // If the rebase was clean, push the rebased PR up to the authors fork. + if (rebaseResult.status === 0) { + info(`Rebase was able to complete automatically without conflicts`); + info(`Pushing rebased PR #${prNumber} to ${fullHeadRef}`); + git.run(['push', headRefUrl, `HEAD:${headRefName}`, forceWithLeaseFlag]); + info(`Rebased and updated PR #${prNumber}`); + git.checkout(previousBranchOrRevision, true); + process.exit(0); + } + } + catch (err) { + error(err.message); + git.checkout(previousBranchOrRevision, true); + process.exit(1); + } + // On automatic rebase failures, prompt to choose if the rebase should be continued + // manually or aborted now. + info(`Rebase was unable to complete automatically without conflicts.`); + // If the command is run in a non-CI environment, prompt to format the files immediately. + const continueRebase = process.env['CI'] === undefined && (yield promptConfirm('Manually complete rebase?')); + if (continueRebase) { + info(`After manually completing rebase, run the following command to update PR #${prNumber}:`); + info(` $ git push ${pr.headRef.repository.url} HEAD:${headRefName} ${forceWithLeaseFlag}`); + info(); + info(`To abort the rebase and return to the state of the repository before this command`); + info(`run the following command:`); + info(` $ git rebase --abort && git reset --hard && git checkout ${previousBranchOrRevision}`); + process.exit(1); + } + else { + info(`Cleaning up git state, and restoring previous state.`); + } + git.checkout(previousBranchOrRevision, true); + process.exit(1); + }); +} + +/** + * @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 rebase pull request command. */ +function buildRebaseCommand(yargs) { + return addGithubTokenOption(yargs).positional('prNumber', { type: 'number', demandOption: true }); +} +/** Handles the rebase pull request command. */ +function handleRebaseCommand({ prNumber, githubToken }) { + return tslib.__awaiter(this, void 0, void 0, function* () { + yield rebasePr(prNumber, githubToken); + }); +} + +/** + * @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 + */ +/** Build the parser for pull request commands. */ +function buildPrParser(localYargs) { + return localYargs.help() + .strict() + .demandCommand() + .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); +} + +/** + * @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 + */ +/** Create logs for each pullapprove group result. */ +function logGroup(group, conditionsToPrint, printMessageFn = info) { + const conditions = group[conditionsToPrint]; + printMessageFn.group(`[${group.groupName}]`); + if (conditions.length) { + conditions.forEach(groupCondition => { + const count = groupCondition.matchedFiles.size; + if (conditionsToPrint === 'unverifiableConditions') { + printMessageFn(`${groupCondition.expression}`); + } + else { + printMessageFn(`${count} ${count === 1 ? 'match' : 'matches'} - ${groupCondition.expression}`); + } + }); + printMessageFn.groupEnd(); + } +} +/** Logs a header within a text drawn box. */ +function logHeader(...params) { + const totalWidth = 80; + const fillWidth = totalWidth - 2; + const headerText = params.join(' ').substr(0, fillWidth); + const leftSpace = Math.ceil((fillWidth - headerText.length) / 2); + const rightSpace = fillWidth - leftSpace - headerText.length; + const fill = (count, content) => content.repeat(count); + info(`┌${fill(fillWidth, '─')}┐`); + info(`│${fill(leftSpace, ' ')}${headerText}${fill(rightSpace, ' ')}│`); + info(`└${fill(fillWidth, '─')}┘`); +} + +/** + * @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 + */ +/** Map that holds patterns and their corresponding Minimatch globs. */ +const patternCache = new Map(); +/** + * Gets a glob for the given pattern. The cached glob will be returned + * if available. Otherwise a new glob will be created and cached. + */ +function getOrCreateGlob(pattern) { + if (patternCache.has(pattern)) { + return patternCache.get(pattern); + } + const glob = new minimatch.Minimatch(pattern, { dot: true }); + patternCache.set(pattern, glob); + return glob; +} + +class PullApproveGroupStateDependencyError extends Error { + constructor(message) { + super(message); + // Set the prototype explicitly because in ES5, the prototype is accidentally + // lost due to a limitation in down-leveling. + // https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work. + Object.setPrototypeOf(this, PullApproveGroupStateDependencyError.prototype); + // Error names are displayed in their stack but can't be set in the constructor. + this.name = PullApproveGroupStateDependencyError.name; + } +} +/** + * Superset of a native array. The superset provides methods which mimic the + * list data structure used in PullApprove for files in conditions. + */ +class PullApproveStringArray extends Array { + constructor(...elements) { + super(...elements); + // Set the prototype explicitly because in ES5, the prototype is accidentally + // lost due to a limitation in down-leveling. + // https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work. + Object.setPrototypeOf(this, PullApproveStringArray.prototype); + } + /** Returns a new array which only includes files that match the given pattern. */ + include(pattern) { + return new PullApproveStringArray(...this.filter(s => getOrCreateGlob(pattern).match(s))); + } + /** Returns a new array which only includes files that did not match the given pattern. */ + exclude(pattern) { + return new PullApproveStringArray(...this.filter(s => !getOrCreateGlob(pattern).match(s))); + } +} +/** + * Superset of a native array. The superset provides methods which mimic the + * list data structure used in PullApprove for groups in conditions. + */ +class PullApproveGroupArray extends Array { + constructor(...elements) { + super(...elements); + // Set the prototype explicitly because in ES5, the prototype is accidentally + // lost due to a limitation in down-leveling. + // https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work. + Object.setPrototypeOf(this, PullApproveGroupArray.prototype); + } + include(pattern) { + return new PullApproveGroupArray(...this.filter(s => s.groupName.match(pattern))); + } + /** Returns a new array which only includes files that did not match the given pattern. */ + exclude(pattern) { + return new PullApproveGroupArray(...this.filter(s => s.groupName.match(pattern))); + } + get pending() { + throw new PullApproveGroupStateDependencyError(); + } + get active() { + throw new PullApproveGroupStateDependencyError(); + } + get inactive() { + throw new PullApproveGroupStateDependencyError(); + } + get rejected() { + throw new PullApproveGroupStateDependencyError(); + } + get names() { + return this.map(g => g.groupName); + } +} + +/** + * @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 + */ +/** + * Context that is provided to conditions. Conditions can use various helpers + * that PullApprove provides. We try to mock them here. Consult the official + * docs for more details: https://docs.pullapprove.com/config/conditions. + */ +const conditionContext = { + 'len': (value) => value.length, + 'contains_any_globs': (files, patterns) => { + // Note: Do not always create globs for the same pattern again. This method + // could be called for each source file. Creating glob's is expensive. + return files.some(f => patterns.some(pattern => getOrCreateGlob(pattern).match(f))); + }, +}; +/** + * Converts a given condition to a function that accepts a set of files. The returned + * function can be called to check if the set of files matches the condition. + */ +function convertConditionToFunction(expr) { + // Creates a dynamic function with the specified expression. + // The first parameter will be `files` as that corresponds to the supported `files` variable that + // can be accessed in PullApprove condition expressions. The second parameter is the list of + // PullApproveGroups that are accessible in the condition expressions. The followed parameters + // correspond to other context variables provided by PullApprove for conditions. + const evaluateFn = new Function('files', 'groups', ...Object.keys(conditionContext), ` + return (${transformExpressionToJs(expr)}); + `); + // Create a function that calls the dynamically constructed function which mimics + // the condition expression that is usually evaluated with Python in PullApprove. + return (files, groups) => { + const result = evaluateFn(new PullApproveStringArray(...files), new PullApproveGroupArray(...groups), ...Object.values(conditionContext)); + // If an array is returned, we consider the condition as active if the array is not + // empty. This matches PullApprove's condition evaluation that is based on Python. + if (Array.isArray(result)) { + return result.length !== 0; + } + return !!result; + }; +} +/** + * Transforms a condition expression from PullApprove that is based on python + * so that it can be run inside JavaScript. Current transformations: + * 1. `not <..>` -> `!<..>` + */ +function transformExpressionToJs(expression) { + return expression.replace(/not\s+/g, '!'); +} + +/** + * @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 + */ +// Regular expression that matches conditions for the global approval. +const GLOBAL_APPROVAL_CONDITION_REGEX = /^"global-(docs-)?approvers" not in groups.approved$/; +// Name of the PullApprove group that serves as fallback. This group should never capture +// any conditions as it would always match specified files. This is not desired as we want +// to figure out as part of this tool, whether there actually are unmatched files. +const FALLBACK_GROUP_NAME = 'fallback'; +/** A PullApprove group to be able to test files against. */ +class PullApproveGroup { + constructor(groupName, config, precedingGroups = []) { + this.groupName = groupName; + this.precedingGroups = precedingGroups; + /** List of conditions for the group. */ + this.conditions = []; + this._captureConditions(config); + } + _captureConditions(config) { + if (config.conditions && this.groupName !== FALLBACK_GROUP_NAME) { + return config.conditions.forEach(condition => { + const expression = condition.trim(); + if (expression.match(GLOBAL_APPROVAL_CONDITION_REGEX)) { + // Currently a noop as we don't take any action for global approval conditions. + return; + } + try { + this.conditions.push({ + expression, + checkFn: convertConditionToFunction(expression), + matchedFiles: new Set(), + unverifiable: false, + }); + } + catch (e) { + error(`Could not parse condition in group: ${this.groupName}`); + error(` - ${expression}`); + error(`Error:`); + error(e.message); + error(e.stack); + process.exit(1); + } + }); + } + } + /** + * Tests a provided file path to determine if it would be considered matched by + * the pull approve group's conditions. + */ + testFile(filePath) { + return this.conditions.every((condition) => { + const { matchedFiles, checkFn, expression } = condition; + try { + const matchesFile = checkFn([filePath], this.precedingGroups); + if (matchesFile) { + matchedFiles.add(filePath); + } + return matchesFile; + } + catch (e) { + // In the case of a condition that depends on the state of groups we want to + // ignore that the verification can't accurately evaluate the condition and then + // continue processing. Other types of errors fail the verification, as conditions + // should otherwise be able to execute without throwing. + if (e instanceof PullApproveGroupStateDependencyError) { + condition.unverifiable = true; + // Return true so that `this.conditions.every` can continue evaluating. + return true; + } + else { + const errMessage = `Condition could not be evaluated: \n\n` + + `From the [${this.groupName}] group:\n` + + ` - ${expression}` + + `\n\n${e.message} ${e.stack}\n\n`; + error(errMessage); + process.exit(1); + } + } + }); + } + /** Retrieve the results for the Group, all matched and unmatched conditions. */ + getResults() { + const matchedConditions = this.conditions.filter(c => c.matchedFiles.size > 0); + const unmatchedConditions = this.conditions.filter(c => c.matchedFiles.size === 0 && !c.unverifiable); + const unverifiableConditions = this.conditions.filter(c => c.unverifiable); + return { + matchedConditions, + matchedCount: matchedConditions.length, + unmatchedConditions, + unmatchedCount: unmatchedConditions.length, + unverifiableConditions, + groupName: this.groupName, + }; + } +} + +/** + * @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 parsePullApproveYaml(rawYaml) { + return yaml.parse(rawYaml, { merge: true }); +} +/** Parses all of the groups defined in the pullapprove yaml. */ +function getGroupsFromYaml(pullApproveYamlRaw) { + /** JSON representation of the pullapprove yaml file. */ + const pullApprove = parsePullApproveYaml(pullApproveYamlRaw); + return Object.entries(pullApprove.groups).reduce((groups, [groupName, group]) => { + return groups.concat(new PullApproveGroup(groupName, group, groups)); + }, []); +} + +/** + * @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 verify$1() { + /** Full path to PullApprove config file */ + const PULL_APPROVE_YAML_PATH = path.resolve(getRepoBaseDir(), '.pullapprove.yml'); + /** All tracked files in the repository. */ + const REPO_FILES = allFiles(); + /** The pull approve config file. */ + const pullApproveYamlRaw = fs.readFileSync(PULL_APPROVE_YAML_PATH, 'utf8'); + /** All of the groups defined in the pullapprove yaml. */ + const groups = getGroupsFromYaml(pullApproveYamlRaw); + /** + * PullApprove groups without conditions. These are skipped in the verification + * as those would always be active and cause zero unmatched files. + */ + const groupsSkipped = groups.filter(group => !group.conditions.length); + /** PullApprove groups with conditions. */ + const groupsWithConditions = groups.filter(group => !!group.conditions.length); + /** Files which are matched by at least one group. */ + const matchedFiles = []; + /** Files which are not matched by at least one group. */ + const unmatchedFiles = []; + // Test each file in the repo against each group for being matched. + REPO_FILES.forEach((file) => { + if (groupsWithConditions.filter(group => group.testFile(file)).length) { + matchedFiles.push(file); + } + else { + unmatchedFiles.push(file); + } + }); + /** Results for each group */ + const resultsByGroup = groupsWithConditions.map(group => group.getResults()); + /** + * Whether all group condition lines match at least one file and all files + * are matched by at least one group. + */ + const verificationSucceeded = resultsByGroup.every(r => !r.unmatchedCount) && !unmatchedFiles.length; + /** + * Overall result + */ + logHeader('Overall Result'); + if (verificationSucceeded) { + info('PullApprove verification succeeded!'); + } + else { + info(`PullApprove verification failed.`); + info(); + info(`Please update '.pullapprove.yml' to ensure that all necessary`); + info(`files/directories have owners and all patterns that appear in`); + info(`the file correspond to actual files/directories in the repo.`); + } + /** + * File by file Summary + */ + logHeader('PullApprove results by file'); + info.group(`Matched Files (${matchedFiles.length} files)`); + matchedFiles.forEach(file => debug(file)); + info.groupEnd(); + info.group(`Unmatched Files (${unmatchedFiles.length} files)`); + unmatchedFiles.forEach(file => info(file)); + info.groupEnd(); + /** + * Group by group Summary + */ + logHeader('PullApprove results by group'); + info.group(`Groups skipped (${groupsSkipped.length} groups)`); + groupsSkipped.forEach(group => debug(`${group.groupName}`)); + info.groupEnd(); + const matchedGroups = resultsByGroup.filter(group => !group.unmatchedCount); + info.group(`Matched conditions by Group (${matchedGroups.length} groups)`); + matchedGroups.forEach(group => logGroup(group, 'matchedConditions', debug)); + info.groupEnd(); + const unmatchedGroups = resultsByGroup.filter(group => group.unmatchedCount); + info.group(`Unmatched conditions by Group (${unmatchedGroups.length} groups)`); + unmatchedGroups.forEach(group => logGroup(group, 'unmatchedConditions')); + info.groupEnd(); + const unverifiableConditionsInGroups = resultsByGroup.filter(group => group.unverifiableConditions.length > 0); + info.group(`Unverifiable conditions by Group (${unverifiableConditionsInGroups.length} groups)`); + unverifiableConditionsInGroups.forEach(group => logGroup(group, 'unverifiableConditions')); + info.groupEnd(); + // Provide correct exit code based on verification success. + process.exit(verificationSucceeded ? 0 : 1); +} + +/** Build the parser for the pullapprove commands. */ +function buildPullapproveParser(localYargs) { + return localYargs.help().strict().demandCommand().command('verify', 'Verify the pullapprove config', {}, () => verify$1()); +} + +/** + * @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 + */ +/** Retrieve and validate the config as `ReleaseConfig`. */ +function getReleaseConfig(config = getConfig()) { + var _a, _b, _c; + // List of errors encountered validating the config. + const errors = []; + if (config.release === undefined) { + errors.push(`No configuration defined for "release"`); + } + if (((_a = config.release) === null || _a === void 0 ? void 0 : _a.npmPackages) === undefined) { + errors.push(`No "npmPackages" configured for releasing.`); + } + if (((_b = config.release) === null || _b === void 0 ? void 0 : _b.buildPackages) === undefined) { + errors.push(`No "buildPackages" function configured for releasing.`); + } + if (((_c = config.release) === null || _c === void 0 ? void 0 : _c.generateReleaseNotesForHead) === undefined) { + errors.push(`No "generateReleaseNotesForHead" function configured for releasing.`); + } + assertNoErrors(errors); + return config.release; +} + +/** + * @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 release output without polluting the process stdout. Build scripts commonly + * print messages to stderr or stdout. This is fine in most cases, but sometimes other tooling + * reserves stdout for data transfer (e.g. when `ng release build --json` is invoked). To not + * pollute the stdout in such cases, we launch a child process for building the release packages + * and redirect all stdout output to the stderr channel (which can be read in the terminal). + */ +function buildReleaseOutput() { + return tslib.__awaiter(this, void 0, void 0, function* () { + return new Promise(resolve => { + const buildProcess = child_process.fork(require.resolve('./build-worker'), [], { + // The stdio option is set to redirect any "stdout" output directly to the "stderr" file + // descriptor. An additional "ipc" file descriptor is created to support communication with + // the build process. https://nodejs.org/api/child_process.html#child_process_options_stdio. + stdio: ['inherit', 2, 2, 'ipc'], + }); + let builtPackages = null; + // The child process will pass the `buildPackages()` output through the + // IPC channel. We keep track of it so that we can use it as resolve value. + buildProcess.on('message', buildResponse => builtPackages = buildResponse); + // On child process exit, resolve the promise with the received output. + buildProcess.on('exit', () => resolve(builtPackages)); + }); + }); +} + +/** + * @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 + */ +/** Yargs command builder for configuring the `ng-dev release build` command. */ +function builder$6(argv) { + return argv.option('json', { + type: 'boolean', + description: 'Whether the built packages should be printed to stdout as JSON.', + default: false, + }); +} +/** Yargs command handler for building a release. */ +function handler$6(args) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { npmPackages } = getReleaseConfig(); + let builtPackages = yield buildReleaseOutput(); + // If package building failed, print an error and exit with an error code. + if (builtPackages === null) { + error(red(` ✘ Could not build release output. Please check output above.`)); + process.exit(1); + } + // If no packages have been built, we assume that this is never correct + // and exit with an error code. + if (builtPackages.length === 0) { + error(red(` ✘ No release packages have been built. Please ensure that the`)); + error(red(` build script is configured correctly in ".ng-dev".`)); + process.exit(1); + } + const missingPackages = npmPackages.filter(pkgName => !builtPackages.find(b => b.name === pkgName)); + // Check for configured release packages which have not been built. We want to + // error and exit if any configured package has not been built. + if (missingPackages.length > 0) { + error(red(` ✘ Release output missing for the following packages:`)); + missingPackages.forEach(pkgName => error(red(` - ${pkgName}`))); + process.exit(1); + } + if (args.json) { + process.stdout.write(JSON.stringify(builtPackages, null, 2)); + } + else { + info(green(' ✓ Built release packages.')); + builtPackages.forEach(({ name }) => info(green(` - ${name}`))); + } + }); +} +/** CLI command module for building release output. */ +const ReleaseBuildCommandModule = { + builder: builder$6, + handler: handler$6, + command: 'build', + describe: 'Builds the release output for the current branch.', +}; + +/** + * @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 + */ +/** + * Prints the active release trains to the console. + * @params active Active release trains that should be printed. + * @params config Release configuration used for querying NPM on published versions. + */ +function printActiveReleaseTrains(active, config) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { releaseCandidate, next, latest } = active; + const isNextPublishedToNpm = yield isVersionPublishedToNpm(next.version, config); + const nextTrainType = next.isMajor ? 'major' : 'minor'; + const ltsBranches = yield fetchLongTermSupportBranchesFromNpm(config); + info(); + info(blue('Current version branches in the project:')); + // Print information for release trains in the feature-freeze/release-candidate phase. + if (releaseCandidate !== null) { + const rcVersion = releaseCandidate.version; + const rcTrainType = releaseCandidate.isMajor ? 'major' : 'minor'; + const rcTrainPhase = rcVersion.prerelease[0] === 'next' ? 'feature-freeze' : 'release-candidate'; + info(` • ${bold(releaseCandidate.branchName)} contains changes for an upcoming ` + + `${rcTrainType} that is currently in ${bold(rcTrainPhase)} phase.`); + info(` Most recent pre-release for this branch is "${bold(`v${rcVersion}`)}".`); + } + // Print information about the release-train in the latest phase. i.e. the patch branch. + info(` • ${bold(latest.branchName)} contains changes for the most recent patch.`); + info(` Most recent patch version for this branch is "${bold(`v${latest.version}`)}".`); + // Print information about the release-train in the next phase. + info(` • ${bold(next.branchName)} contains changes for a ${nextTrainType} ` + + `currently in active development.`); + // Note that there is a special case for versions in the next release-train. The version in + // the next branch is not always published to NPM. This can happen when we recently branched + // off for a feature-freeze release-train. More details are in the next pre-release action. + if (isNextPublishedToNpm) { + info(` Most recent pre-release version for this branch is "${bold(`v${next.version}`)}".`); + } + else { + info(` Version is currently set to "${bold(`v${next.version}`)}", but has not been ` + + `published yet.`); + } + // If no release-train in release-candidate or feature-freeze phase is active, + // we print a message as last bullet point to make this clear. + if (releaseCandidate === null) { + info(' • No release-candidate or feature-freeze branch currently active.'); + } + info(); + info(blue('Current active LTS version branches:')); + // Print all active LTS branches (each branch as own bullet point). + if (ltsBranches.active.length !== 0) { + for (const ltsBranch of ltsBranches.active) { + info(` • ${bold(ltsBranch.name)} is currently in active long-term support phase.`); + info(` Most recent patch version for this branch is "${bold(`v${ltsBranch.version}`)}".`); + } + } + info(); + }); +} + +/** + * @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 + */ +/** Error that will be thrown if the user manually aborted a release action. */ +class UserAbortedReleaseActionError extends Error { + constructor() { + super(); + // Set the prototype explicitly because in ES5, the prototype is accidentally lost due to + // a limitation in down-leveling. + // https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work. + Object.setPrototypeOf(this, UserAbortedReleaseActionError.prototype); + } +} +/** Error that will be thrown if the action has been aborted due to a fatal error. */ +class FatalReleaseActionError extends Error { + constructor() { + super(); + // Set the prototype explicitly because in ES5, the prototype is accidentally lost due to + // a limitation in down-leveling. + // https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work. + Object.setPrototypeOf(this, FatalReleaseActionError.prototype); + } +} + +/** + * @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 + */ +/** + * Increments a specified SemVer version. Compared to the original increment in SemVer, + * the version is cloned to not modify the original version instance. + */ +function semverInc(version, release, identifier) { + const clone = new semver.SemVer(version.version); + return clone.inc(release, identifier); +} + +/** + * @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 + */ +/** + * Spawns a given command with the specified arguments inside a shell. All process stdout + * output is captured and returned as resolution on completion. Depending on the chosen + * output mode, stdout/stderr output is also printed to the console, or only on error. + * + * @returns a Promise resolving with captured stdout on success. The promise + * rejects on command failure. + */ +function spawnWithDebugOutput(command, args, options) { + if (options === void 0) { options = {}; } + return new Promise(function (resolve, reject) { + var commandText = command + " " + args.join(' '); + var outputMode = options.mode; + debug("Executing command: " + commandText); + var childProcess = child_process.spawn(command, args, tslib.__assign(tslib.__assign({}, options), { shell: true, stdio: ['inherit', 'pipe', 'pipe'] })); + var logOutput = ''; + var stdout = ''; + // Capture the stdout separately so that it can be passed as resolve value. + // This is useful if commands return parsable stdout. + childProcess.stderr.on('data', function (message) { + logOutput += message; + // If console output is enabled, print the message directly to the stderr. Note that + // we intentionally print all output to stderr as stdout should not be polluted. + if (outputMode === undefined || outputMode === 'enabled') { + process.stderr.write(message); + } + }); + childProcess.stdout.on('data', function (message) { + stdout += message; + logOutput += message; + // If console output is enabled, print the message directly to the stderr. Note that + // we intentionally print all output to stderr as stdout should not be polluted. + if (outputMode === undefined || outputMode === 'enabled') { + process.stderr.write(message); + } + }); + childProcess.on('exit', function (status, signal) { + var exitDescription = status !== null ? "exit code \"" + status + "\"" : "signal \"" + signal + "\""; + var printFn = outputMode === 'on-error' ? error : debug; + printFn("Command \"" + commandText + "\" completed with " + exitDescription + "."); + printFn("Process output: \n" + logOutput); + // On success, resolve the promise. Otherwise reject with the captured stderr + // and stdout log output if the output mode was set to `silent`. + if (status === 0) { + resolve({ stdout: stdout }); + } + else { + reject(outputMode === 'silent' ? logOutput : undefined); + } + }); + }); +} + +/** + * @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 + */ +/** + * Runs NPM publish within a specified package directory. + * @throws With the process log output if the publish failed. + */ +function runNpmPublish(packagePath, distTag, registryUrl) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const args = ['publish', '--access', 'public', '--tag', distTag]; + // If a custom registry URL has been specified, add the `--registry` flag. + if (registryUrl !== undefined) { + args.push('--registry', registryUrl); + } + yield spawnWithDebugOutput('npm', args, { cwd: packagePath, mode: 'silent' }); + }); +} +/** + * Sets the NPM tag to the specified version for the given package. + * @throws With the process log output if the tagging failed. + */ +function setNpmTagForPackage(packageName, distTag, version, registryUrl) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const args = ['dist-tag', 'add', `${packageName}@${version}`, distTag]; + // If a custom registry URL has been specified, add the `--registry` flag. + if (registryUrl !== undefined) { + args.push('--registry', registryUrl); + } + yield spawnWithDebugOutput('npm', args, { mode: 'silent' }); + }); +} + +/** + * @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 + */ +/** Gets the commit message for a new release point in the project. */ +function getCommitMessageForRelease(newVersion) { + return `release: cut the v${newVersion} release`; +} +/** + * Gets the commit message for an exceptional version bump in the next branch. The next + * branch version will be bumped without the release being published in some situations. + * More details can be found in the `MoveNextIntoFeatureFreeze` release action and in: + * https://hackmd.io/2Le8leq0S6G_R5VEVTNK9A. + */ +function getCommitMessageForExceptionalNextVersionBump(newVersion) { + return `release: bump the next branch to v${newVersion}`; +} +/** Gets the commit message for a release notes cherry-pick commit */ +function getReleaseNoteCherryPickCommitMessage(newVersion) { + return `docs: release notes for the v${newVersion} release`; +} + +/** + * @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 + */ +/** Project-relative path for the changelog file. */ +const changelogPath = 'CHANGELOG.md'; +/** Project-relative path for the "package.json" file. */ +const packageJsonPath = 'package.json'; +/** Default interval in milliseconds to check whether a pull request has been merged. */ +const waitForPullRequestInterval = 10000; + +/** + * @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 + */ +/* + * ############################################################### + * + * This file contains helpers for invoking external `ng-dev` commands. A subset of actions, + * like building release output or setting aν NPM dist tag for release packages, cannot be + * performed directly as part of the release tool and need to be delegated to external `ng-dev` + * commands that exist across arbitrary version branches. + * + * In a concrete example: Consider a new patch version is released and that a new release + * package has been added to the `next` branch. The patch branch will not contain the new + * release package, so we could not build the release output for it. To work around this, we + * call the ng-dev build command for the patch version branch and expect it to return a list + * of built packages that need to be released as part of this release train. + * + * ############################################################### + */ +/** + * Invokes the `ng-dev release set-dist-tag` command in order to set the specified + * NPM dist tag for all packages in the checked out branch to the given version. + */ +function invokeSetNpmDistCommand(npmDistTag, version) { + return tslib.__awaiter(this, void 0, void 0, function* () { + try { + // Note: No progress indicator needed as that is the responsibility of the command. + yield spawnWithDebugOutput('yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]); + info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`)); + } + catch (e) { + error(e); + error(red(` ✘ An error occurred while setting the NPM dist tag for "${npmDistTag}".`)); + throw new FatalReleaseActionError(); + } + }); +} +/** + * Invokes the `ng-dev release build` command in order to build the release + * packages for the currently checked out branch. + */ +function invokeReleaseBuildCommand() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const spinner = ora.call(undefined).start('Building release output.'); + try { + // Since we expect JSON to be printed from the `ng-dev release build` command, + // we spawn the process in silent mode. We have set up an Ora progress spinner. + const { stdout } = yield spawnWithDebugOutput('yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], { mode: 'silent' }); + spinner.stop(); + info(green(' ✓ Built release output for all packages.')); + // The `ng-dev release build` command prints a JSON array to stdout + // that represents the built release packages and their output paths. + return JSON.parse(stdout.trim()); + } + catch (e) { + spinner.stop(); + error(e); + error(red(' ✘ An error occurred while building the release packages.')); + throw new FatalReleaseActionError(); + } + }); +} +/** + * Invokes the `yarn install` command in order to install dependencies for + * the configured project with the currently checked out revision. + */ +function invokeYarnInstallCommand(projectDir) { + return tslib.__awaiter(this, void 0, void 0, function* () { + try { + // Note: No progress indicator needed as that is the responsibility of the command. + // TODO: Consider using an Ora spinner instead to ensure minimal console output. + yield spawnWithDebugOutput('yarn', ['install', '--frozen-lockfile', '--non-interactive'], { cwd: projectDir }); + info(green(' ✓ Installed project dependencies.')); + } + catch (e) { + error(e); + error(red(' ✘ An error occurred while installing dependencies.')); + throw new FatalReleaseActionError(); + } + }); +} + +/** + * @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 + */ +/** + * Graphql Github API query that can be used to find forks of a given repository + * that are owned by the current viewer authenticated with the Github API. + */ +const findOwnedForksOfRepoQuery = typedGraphqlify.params({ + $owner: 'String!', + $name: 'String!', +}, { + repository: typedGraphqlify.params({ owner: '$owner', name: '$name' }, { + forks: typedGraphqlify.params({ affiliations: 'OWNER', first: 1 }, { + nodes: [{ + owner: { + login: typedGraphqlify.types.string, + }, + name: typedGraphqlify.types.string, + }], + }), + }), +}); + +/** + * @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 + */ +/** Gets whether a given pull request has been merged. */ +function getPullRequestState(api, id) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { data } = yield api.github.pulls.get(Object.assign(Object.assign({}, api.remoteParams), { pull_number: id })); + if (data.merged) { + return 'merged'; + } + else if (data.closed_at !== null) { + return (yield isPullRequestClosedWithAssociatedCommit(api, id)) ? 'merged' : 'closed'; + } + else { + return 'open'; + } + }); +} +/** + * Whether the pull request has been closed with an associated commit. This is usually + * the case if a PR has been merged using the autosquash merge script strategy. Since + * the merge is not fast-forward, Github does not consider the PR as merged and instead + * shows the PR as closed. See for example: https://github.com/angular/angular/pull/37918. + */ +function isPullRequestClosedWithAssociatedCommit(api, id) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const request = api.github.issues.listEvents.endpoint.merge(Object.assign(Object.assign({}, api.remoteParams), { issue_number: id })); + const events = yield api.github.paginate(request); + // Iterate through the events of the pull request in reverse. We want to find the most + // recent events and check if the PR has been closed with a commit associated with it. + // If the PR has been closed through a commit, we assume that the PR has been merged + // using the autosquash merge strategy. For more details. See the `AutosquashMergeStrategy`. + for (let i = events.length - 1; i >= 0; i--) { + const { event, commit_id } = events[i]; + // If we come across a "reopened" event, we abort looking for referenced commits. Any + // commits that closed the PR before, are no longer relevant and did not close the PR. + if (event === 'reopened') { + return false; + } + // If a `closed` event is captured with a commit assigned, then we assume that + // this PR has been merged properly. + if (event === 'closed' && commit_id) { + return true; + } + // If the PR has been referenced by a commit, check if the commit closes this pull + // request. Note that this is needed besides checking `closed` as PRs could be merged + // into any non-default branch where the `Closes <..>` keyword does not work and the PR + // is simply closed without an associated `commit_id`. For more details see: + // https://docs.github.com/en/enterprise/2.16/user/github/managing-your-work-on-github/closing-issues-using-keywords#:~:text=non-default. + if (event === 'referenced' && commit_id && + (yield isCommitClosingPullRequest(api, commit_id, id))) { + return true; + } + } + return false; + }); +} +/** Checks whether the specified commit is closing the given pull request. */ +function isCommitClosingPullRequest(api, sha, id) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { data } = yield api.github.repos.getCommit(Object.assign(Object.assign({}, api.remoteParams), { ref: sha })); + // Matches the closing keyword supported in commit messages. See: + // https://docs.github.com/en/enterprise/2.16/user/github/managing-your-work-on-github/closing-issues-using-keywords. + return data.commit.message.match(new RegExp(`(?:close[sd]?|fix(?:e[sd]?)|resolve[sd]?):? #${id}(?!\\d)`, 'i')); + }); +} + +/** + * @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 + */ +/** + * Gets the default pattern for extracting release notes for the given version. + * This pattern matches for the conventional-changelog Angular preset. + */ +function getDefaultExtractReleaseNotesPattern(version) { + const escapedVersion = version.format().replace('.', '\\.'); + // TODO: Change this once we have a canonical changelog generation tool. Also update this + // based on the conventional-changelog version. They removed anchors in more recent versions. + return new RegExp(`(.*?)(?: { + debug(`Waiting for pull request #${id} to be merged.`); + const spinner = ora.call(undefined).start(`Waiting for pull request #${id} to be merged.`); + const intervalId = setInterval(() => tslib.__awaiter(this, void 0, void 0, function* () { + const prState = yield getPullRequestState(this.git, id); + if (prState === 'merged') { + spinner.stop(); + info(green(` ✓ Pull request #${id} has been merged.`)); + clearInterval(intervalId); + resolve(); + } + else if (prState === 'closed') { + spinner.stop(); + warn(yellow(` ✘ Pull request #${id} has been closed.`)); + clearInterval(intervalId); + reject(new UserAbortedReleaseActionError()); + } + }), interval); + }); + }); + } + /** + * Prepend releases notes for a version published in a given branch to the changelog in + * the current Git `HEAD`. This is useful for cherry-picking the changelog. + * @returns A boolean indicating whether the release notes have been prepended. + */ + prependReleaseNotesFromVersionBranch(version, containingBranch) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { data } = yield this.git.github.repos.getContents(Object.assign(Object.assign({}, this.git.remoteParams), { path: '/' + changelogPath, ref: containingBranch })); + const branchChangelog = Buffer.from(data.content, 'base64').toString(); + let releaseNotes = this._extractReleaseNotesForVersion(branchChangelog, version); + // If no release notes could be extracted, return "false" so that the caller + // can tell that changelog prepending failed. + if (releaseNotes === null) { + return false; + } + const localChangelogPath = getLocalChangelogFilePath(this.projectDir); + const localChangelog = yield fs.promises.readFile(localChangelogPath, 'utf8'); + // If the extracted release notes do not have any new lines at the end and the + // local changelog is not empty, we add lines manually so that there is space + // between the previous and cherry-picked release notes. + if (!/[\r\n]+$/.test(releaseNotes) && localChangelog !== '') { + releaseNotes = `${releaseNotes}\n\n`; + } + // Prepend the extracted release notes to the local changelog and write it back. + yield fs.promises.writeFile(localChangelogPath, releaseNotes + localChangelog); + return true; + }); + } + /** Checks out an upstream branch with a detached head. */ + checkoutUpstreamBranch(branchName) { + return tslib.__awaiter(this, void 0, void 0, function* () { + this.git.run(['fetch', this.git.repoGitUrl, branchName]); + this.git.run(['checkout', 'FETCH_HEAD', '--detach']); + }); + } + /** + * Creates a commit for the specified files with the given message. + * @param message Message for the created commit + * @param files List of project-relative file paths to be commited. + */ + createCommit(message, files) { + return tslib.__awaiter(this, void 0, void 0, function* () { + this.git.run(['commit', '--no-verify', '-m', message, ...files]); + }); + } + /** + * Creates a cherry-pick commit for the release notes of the specified version that + * has been pushed to the given branch. + * @returns a boolean indicating whether the commit has been created successfully. + */ + createCherryPickReleaseNotesCommitFrom(version, branchName) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const commitMessage = getReleaseNoteCherryPickCommitMessage(version); + // Fetch, extract and prepend the release notes to the local changelog. If that is not + // possible, abort so that we can ask the user to manually cherry-pick the changelog. + if (!(yield this.prependReleaseNotesFromVersionBranch(version, branchName))) { + return false; + } + // Create a changelog cherry-pick commit. + yield this.createCommit(commitMessage, [changelogPath]); + info(green(` ✓ Created changelog cherry-pick commit for: "${version}".`)); + return true; + }); + } + /** + * Stages the specified new version for the current branch and creates a + * pull request that targets the given base branch. + * @returns an object describing the created pull request. + */ + stageVersionForBranchAndCreatePullRequest(newVersion, pullRequestBaseBranch) { + return tslib.__awaiter(this, void 0, void 0, function* () { + yield this.updateProjectVersion(newVersion); + yield this._generateReleaseNotesForHead(newVersion); + yield this.waitForEditsAndCreateReleaseCommit(newVersion); + const pullRequest = yield this.pushChangesToForkAndCreatePullRequest(pullRequestBaseBranch, `release-stage-${newVersion}`, `Bump version to "v${newVersion}" with changelog.`); + info(green(' ✓ Release staging pull request has been created.')); + info(yellow(` Please ask team members to review: ${pullRequest.url}.`)); + return pullRequest; + }); + } + /** + * Checks out the specified target branch, verifies its CI status and stages + * the specified new version in order to create a pull request. + * @returns an object describing the created pull request. + */ + checkoutBranchAndStageVersion(newVersion, stagingBranch) { + return tslib.__awaiter(this, void 0, void 0, function* () { + yield this.verifyPassingGithubStatus(stagingBranch); + yield this.checkoutUpstreamBranch(stagingBranch); + return yield this.stageVersionForBranchAndCreatePullRequest(newVersion, stagingBranch); + }); + } + /** + * Cherry-picks the release notes of a version that have been pushed to a given branch + * into the `next` primary development branch. A pull request is created for this. + * @returns a boolean indicating successful creation of the cherry-pick pull request. + */ + cherryPickChangelogIntoNextBranch(newVersion, stagingBranch) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const nextBranch = this.active.next.branchName; + const commitMessage = getReleaseNoteCherryPickCommitMessage(newVersion); + // Checkout the next branch. + yield this.checkoutUpstreamBranch(nextBranch); + // Cherry-pick the release notes into the current branch. If it fails, + // ask the user to manually copy the release notes into the next branch. + if (!(yield this.createCherryPickReleaseNotesCommitFrom(newVersion, stagingBranch))) { + error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`)); + error(yellow(` Please copy the release notes manually into the "${nextBranch}" branch.`)); + return false; + } + // Create a cherry-pick pull request that should be merged by the caretaker. + const { url } = yield this.pushChangesToForkAndCreatePullRequest(nextBranch, `changelog-cherry-pick-${newVersion}`, commitMessage, `Cherry-picks the changelog from the "${stagingBranch}" branch to the next ` + + `branch (${nextBranch}).`); + info(green(` ✓ Pull request for cherry-picking the changelog into "${nextBranch}" ` + + 'has been created.')); + info(yellow(` Please ask team members to review: ${url}.`)); + return true; + }); + } + /** + * Creates a Github release for the specified version in the configured project. + * The release is created by tagging the specified commit SHA. + */ + _createGithubReleaseForVersion(newVersion, versionBumpCommitSha) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const tagName = newVersion.format(); + yield this.git.github.git.createRef(Object.assign(Object.assign({}, this.git.remoteParams), { ref: `refs/tags/${tagName}`, sha: versionBumpCommitSha })); + info(green(` ✓ Tagged v${newVersion} release upstream.`)); + yield this.git.github.repos.createRelease(Object.assign(Object.assign({}, this.git.remoteParams), { name: `v${newVersion}`, tag_name: tagName })); + info(green(` ✓ Created v${newVersion} release in Github.`)); + }); + } + /** + * Builds and publishes the given version in the specified branch. + * @param newVersion The new version to be published. + * @param publishBranch Name of the branch that contains the new version. + * @param npmDistTag NPM dist tag where the version should be published to. + */ + buildAndPublish(newVersion, publishBranch, npmDistTag) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const versionBumpCommitSha = yield this._getCommitOfBranch(publishBranch); + if (!(yield this._isCommitForVersionStaging(newVersion, versionBumpCommitSha))) { + error(red(` ✘ Latest commit in "${publishBranch}" branch is not a staging commit.`)); + error(red(' Please make sure the staging pull request has been merged.')); + throw new FatalReleaseActionError(); + } + // Checkout the publish branch and build the release packages. + yield this.checkoutUpstreamBranch(publishBranch); + // Install the project dependencies for the publish branch, and then build the release + // packages. Note that we do not directly call the build packages function from the release + // config. We only want to build and publish packages that have been configured in the given + // publish branch. e.g. consider we publish patch version and a new package has been + // created in the `next` branch. The new package would not be part of the patch branch, + // so we cannot build and publish it. + yield invokeYarnInstallCommand(this.projectDir); + const builtPackages = yield invokeReleaseBuildCommand(); + // Create a Github release for the new version. + yield this._createGithubReleaseForVersion(newVersion, versionBumpCommitSha); + // Walk through all built packages and publish them to NPM. + for (const builtPackage of builtPackages) { + yield this._publishBuiltPackageToNpm(builtPackage, npmDistTag); + } + info(green(' ✓ Published all packages successfully')); + }); + } + /** Publishes the given built package to NPM with the specified NPM dist tag. */ + _publishBuiltPackageToNpm(pkg, npmDistTag) { + return tslib.__awaiter(this, void 0, void 0, function* () { + debug(`Starting publish of "${pkg.name}".`); + const spinner = ora.call(undefined).start(`Publishing "${pkg.name}"`); + try { + yield runNpmPublish(pkg.outputPath, npmDistTag, this.config.publishRegistry); + spinner.stop(); + info(green(` ✓ Successfully published "${pkg.name}.`)); + } + catch (e) { + spinner.stop(); + error(e); + error(red(` ✘ An error occurred while publishing "${pkg.name}".`)); + throw new FatalReleaseActionError(); + } + }); + } + /** Checks whether the given commit represents a staging commit for the specified version. */ + _isCommitForVersionStaging(version, commitSha) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { data } = yield this.git.github.repos.getCommit(Object.assign(Object.assign({}, this.git.remoteParams), { ref: commitSha })); + return data.commit.message.startsWith(getCommitMessageForRelease(version)); + }); + } +} + +/** + * @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 + */ +/** + * Release action that cuts a new patch release for an active release-train in the long-term + * support phase. The patch segment is incremented. The changelog is generated for the new + * patch version, but also needs to be cherry-picked into the next development branch. + */ +class CutLongTermSupportPatchAction extends ReleaseAction { + constructor() { + super(...arguments); + /** Promise resolving an object describing long-term support branches. */ + this.ltsBranches = fetchLongTermSupportBranchesFromNpm(this.config); + } + getDescription() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { active } = yield this.ltsBranches; + return `Cut a new release for an active LTS branch (${active.length} active).`; + }); + } + perform() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const ltsBranch = yield this._promptForTargetLtsBranch(); + const newVersion = semverInc(ltsBranch.version, 'patch'); + const { id } = yield this.checkoutBranchAndStageVersion(newVersion, ltsBranch.name); + yield this.waitForPullRequestToBeMerged(id); + yield this.buildAndPublish(newVersion, ltsBranch.name, ltsBranch.npmDistTag); + yield this.cherryPickChangelogIntoNextBranch(newVersion, ltsBranch.name); + }); + } + /** Prompts the user to select an LTS branch for which a patch should but cut. */ + _promptForTargetLtsBranch() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { active, inactive } = yield this.ltsBranches; + const activeBranchChoices = active.map(branch => this._getChoiceForLtsBranch(branch)); + // If there are inactive LTS branches, we allow them to be selected. In some situations, + // patch releases are still cut for inactive LTS branches. e.g. when the LTS duration + // has been increased due to exceptional events () + if (inactive.length !== 0) { + activeBranchChoices.push({ name: 'Inactive LTS versions (not recommended)', value: null }); + } + const { activeLtsBranch, inactiveLtsBranch } = yield inquirer.prompt([ + { + name: 'activeLtsBranch', + type: 'list', + message: 'Please select a version for which you want to cut an LTS patch', + choices: activeBranchChoices, + }, + { + name: 'inactiveLtsBranch', + type: 'list', + when: o => o.activeLtsBranch === null, + message: 'Please select an inactive LTS version for which you want to cut an LTS patch', + choices: inactive.map(branch => this._getChoiceForLtsBranch(branch)), + } + ]); + return activeLtsBranch !== null && activeLtsBranch !== void 0 ? activeLtsBranch : inactiveLtsBranch; + }); + } + /** Gets an inquirer choice for the given LTS branch. */ + _getChoiceForLtsBranch(branch) { + return { name: `v${branch.version.major} (from ${branch.name})`, value: branch }; + } + static isActive(active) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // LTS patch versions can be only cut if there are release trains in LTS phase. + // This action is always selectable as we support publishing of old LTS branches, + // and have prompt for selecting an LTS branch when the action performs. + return true; + }); + } +} + +/** + * @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 + */ +/** + * Release action that cuts a new patch release for the current latest release-train version + * branch (i.e. the patch branch). The patch segment is incremented. The changelog is generated + * for the new patch version, but also needs to be cherry-picked into the next development branch. + */ +class CutNewPatchAction extends ReleaseAction { + constructor() { + super(...arguments); + this._newVersion = semverInc(this.active.latest.version, 'patch'); + } + getDescription() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName } = this.active.latest; + const newVersion = this._newVersion; + return `Cut a new patch release for the "${branchName}" branch (v${newVersion}).`; + }); + } + perform() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName } = this.active.latest; + const newVersion = this._newVersion; + const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); + yield this.waitForPullRequestToBeMerged(id); + yield this.buildAndPublish(newVersion, branchName, 'latest'); + yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); + }); + } + static isActive(active) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // Patch versions can be cut at any time. See: + // https://hackmd.io/2Le8leq0S6G_R5VEVTNK9A#Release-prompt-options. + return true; + }); + } +} + +/** + * @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 + */ +/** Computes the new pre-release version for the next release-train. */ +function computeNewPrereleaseVersionForNext(active, config) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { version: nextVersion } = active.next; + const isNextPublishedToNpm = yield isVersionPublishedToNpm(nextVersion, config); + // Special-case where the version in the `next` release-train is not published yet. This + // happens when we recently branched off for feature-freeze. We already bump the version to + // the next minor or major, but do not publish immediately. Cutting a release immediately would + // be not helpful as there are no other changes than in the feature-freeze branch. If we happen + // to detect this case, we stage the release as usual but do not increment the version. + if (isNextPublishedToNpm) { + return semverInc(nextVersion, 'prerelease'); + } + else { + return nextVersion; + } + }); +} + +/** + * @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 + */ +/** + * Release action that cuts a prerelease for the next branch. A version in the next + * branch can have an arbitrary amount of next pre-releases. + */ +class CutNextPrereleaseAction extends ReleaseAction { + constructor() { + super(...arguments); + /** Promise resolving with the new version if a NPM next pre-release is cut. */ + this._newVersion = this._computeNewVersion(); + } + getDescription() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName } = this._getActivePrereleaseTrain(); + const newVersion = yield this._newVersion; + return `Cut a new next pre-release for the "${branchName}" branch (v${newVersion}).`; + }); + } + perform() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const releaseTrain = this._getActivePrereleaseTrain(); + const { branchName } = releaseTrain; + const newVersion = yield this._newVersion; + const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); + yield this.waitForPullRequestToBeMerged(id); + yield this.buildAndPublish(newVersion, branchName, 'next'); + // If the pre-release has been cut from a branch that is not corresponding + // to the next release-train, cherry-pick the changelog into the primary + // development branch. i.e. the `next` branch that is usually `master`. + if (releaseTrain !== this.active.next) { + yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); + } + }); + } + /** Gets the release train for which NPM next pre-releases should be cut. */ + _getActivePrereleaseTrain() { + var _a; + return (_a = this.active.releaseCandidate) !== null && _a !== void 0 ? _a : this.active.next; + } + /** Gets the new pre-release version for this release action. */ + _computeNewVersion() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const releaseTrain = this._getActivePrereleaseTrain(); + // If a pre-release is cut for the next release-train, the new version is computed + // with respect to special cases surfacing with FF/RC branches. Otherwise, the basic + // pre-release increment of the version is used as new version. + if (releaseTrain === this.active.next) { + return yield computeNewPrereleaseVersionForNext(this.active, this.config); + } + else { + return semverInc(releaseTrain.version, 'prerelease'); + } + }); + } + static isActive() { + return tslib.__awaiter(this, void 0, void 0, function* () { + // Pre-releases for the `next` NPM dist tag can always be cut. Depending on whether + // there is a feature-freeze/release-candidate branch, the next pre-releases are either + // cut from such a branch, or from the actual `next` release-train branch (i.e. master). + return true; + }); + } +} + +/** + * @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 + */ +/** + * Cuts the first release candidate for a release-train currently in the + * feature-freeze phase. The version is bumped from `next` to `rc.0`. + */ +class CutReleaseCandidateAction extends ReleaseAction { + constructor() { + super(...arguments); + this._newVersion = semverInc(this.active.releaseCandidate.version, 'prerelease', 'rc'); + } + getDescription() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const newVersion = this._newVersion; + return `Cut a first release-candidate for the feature-freeze branch (v${newVersion}).`; + }); + } + perform() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName } = this.active.releaseCandidate; + const newVersion = this._newVersion; + const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); + yield this.waitForPullRequestToBeMerged(id); + yield this.buildAndPublish(newVersion, branchName, 'next'); + yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); + }); + } + static isActive(active) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // A release-candidate can be cut for an active release-train currently + // in the feature-freeze phase. + return active.releaseCandidate !== null && + active.releaseCandidate.version.prerelease[0] === 'next'; + }); + } +} + +/** + * @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 + */ +/** + * Release action that cuts a stable version for the current release-train in the release + * candidate phase. The pre-release release-candidate version label is removed. + */ +class CutStableAction extends ReleaseAction { + constructor() { + super(...arguments); + this._newVersion = this._computeNewVersion(); + } + getDescription() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const newVersion = this._newVersion; + return `Cut a stable release for the release-candidate branch (v${newVersion}).`; + }); + } + perform() { + var _a; + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName } = this.active.releaseCandidate; + const newVersion = this._newVersion; + const isNewMajor = (_a = this.active.releaseCandidate) === null || _a === void 0 ? void 0 : _a.isMajor; + const { id } = yield this.checkoutBranchAndStageVersion(newVersion, branchName); + yield this.waitForPullRequestToBeMerged(id); + yield this.buildAndPublish(newVersion, branchName, 'latest'); + // If a new major version is published and becomes the "latest" release-train, we need + // to set the LTS npm dist tag for the previous latest release-train (the current patch). + if (isNewMajor) { + const previousPatchVersion = this.active.latest.version; + const ltsTagForPatch = getLtsNpmDistTagOfMajor(previousPatchVersion.major); + // Instead of directly setting the NPM dist tags, we invoke the ng-dev command for + // setting the NPM dist tag to the specified version. We do this because release NPM + // packages could be different in the previous patch branch, and we want to set the + // LTS tag for all packages part of the last major. It would not be possible to set the + // NPM dist tag for new packages part of the released major, nor would it be acceptable + // to skip the LTS tag for packages which are no longer part of the new major. + yield invokeYarnInstallCommand(this.projectDir); + yield invokeSetNpmDistCommand(ltsTagForPatch, previousPatchVersion); + } + yield this.cherryPickChangelogIntoNextBranch(newVersion, branchName); + }); + } + /** Gets the new stable version of the release candidate release-train. */ + _computeNewVersion() { + const { version } = this.active.releaseCandidate; + return semver.parse(`${version.major}.${version.minor}.${version.patch}`); + } + static isActive(active) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // A stable version can be cut for an active release-train currently in the + // release-candidate phase. Note: It is not possible to directly release from + // feature-freeze phase into a stable version. + return active.releaseCandidate !== null && + active.releaseCandidate.version.prerelease[0] === 'rc'; + }); + } +} + +/** + * @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 + */ +/** + * Release action that moves the next release-train into the feature-freeze phase. This means + * that a new version branch is created from the next branch, and a new next pre-release is + * cut indicating the started feature-freeze. + */ +class MoveNextIntoFeatureFreezeAction extends ReleaseAction { + constructor() { + super(...arguments); + this._newVersion = computeNewPrereleaseVersionForNext(this.active, this.config); + } + getDescription() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName } = this.active.next; + const newVersion = yield this._newVersion; + return `Move the "${branchName}" branch into feature-freeze phase (v${newVersion}).`; + }); + } + perform() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const newVersion = yield this._newVersion; + const newBranch = `${newVersion.major}.${newVersion.minor}.x`; + // Branch-off the next branch into a feature-freeze branch. + yield this._createNewVersionBranchFromNext(newBranch); + // Stage the new version for the newly created branch, and push changes to a + // fork in order to create a staging pull request. Note that we re-use the newly + // created branch instead of re-fetching from the upstream. + const stagingPullRequest = yield this.stageVersionForBranchAndCreatePullRequest(newVersion, newBranch); + // Wait for the staging PR to be merged. Then build and publish the feature-freeze next + // pre-release. Finally, cherry-pick the release notes into the next branch in combination + // with bumping the version to the next minor too. + yield this.waitForPullRequestToBeMerged(stagingPullRequest.id); + yield this.buildAndPublish(newVersion, newBranch, 'next'); + yield this._createNextBranchUpdatePullRequest(newVersion, newBranch); + }); + } + /** Creates a new version branch from the next branch. */ + _createNewVersionBranchFromNext(newBranch) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName: nextBranch } = this.active.next; + yield this.verifyPassingGithubStatus(nextBranch); + yield this.checkoutUpstreamBranch(nextBranch); + yield this.createLocalBranchFromHead(newBranch); + yield this.pushHeadToRemoteBranch(newBranch); + info(green(` ✓ Version branch "${newBranch}" created.`)); + }); + } + /** + * Creates a pull request for the next branch that bumps the version to the next + * minor, and cherry-picks the changelog for the newly branched-off feature-freeze version. + */ + _createNextBranchUpdatePullRequest(newVersion, newBranch) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { branchName: nextBranch, version } = this.active.next; + // We increase the version for the next branch to the next minor. The team can decide + // later if they want next to be a major through the `Configure Next as Major` release action. + const newNextVersion = semver.parse(`${version.major}.${version.minor + 1}.0-next.0`); + const bumpCommitMessage = getCommitMessageForExceptionalNextVersionBump(newNextVersion); + yield this.checkoutUpstreamBranch(nextBranch); + yield this.updateProjectVersion(newNextVersion); + // Create an individual commit for the next version bump. The changelog should go into + // a separate commit that makes it clear where the changelog is cherry-picked from. + yield this.createCommit(bumpCommitMessage, [packageJsonPath]); + let nextPullRequestMessage = `The previous "next" release-train has moved into the ` + + `release-candidate phase. This PR updates the next branch to the subsequent ` + + `release-train.`; + const hasChangelogCherryPicked = yield this.createCherryPickReleaseNotesCommitFrom(newVersion, newBranch); + if (hasChangelogCherryPicked) { + nextPullRequestMessage += `\n\nAlso this PR cherry-picks the changelog for ` + + `v${newVersion} into the ${nextBranch} branch so that the changelog is up to date.`; + } + else { + error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`)); + error(yellow(` Please copy the release note manually into "${nextBranch}".`)); + } + const nextUpdatePullRequest = yield this.pushChangesToForkAndCreatePullRequest(nextBranch, `next-release-train-${newNextVersion}`, `Update next branch to reflect new release-train "v${newNextVersion}".`, nextPullRequestMessage); + info(green(` ✓ Pull request for updating the "${nextBranch}" branch has been created.`)); + info(yellow(` Please ask team members to review: ${nextUpdatePullRequest.url}.`)); + }); + } + static isActive(active) { + return tslib.__awaiter(this, void 0, void 0, function* () { + // A new feature-freeze/release-candidate branch can only be created if there + // is no active release-train in feature-freeze/release-candidate phase. + return active.releaseCandidate === null; + }); + } +} + +/** + * @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 + */ +/** + * List of release actions supported by the release staging tool. These are sorted + * by priority. Actions which are selectable are sorted based on this declaration order. + */ +const actions = [ + CutStableAction, + CutReleaseCandidateAction, + CutNewPatchAction, + CutNextPrereleaseAction, + MoveNextIntoFeatureFreezeAction, + CutLongTermSupportPatchAction, +]; + +/** + * @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 + */ +var CompletionState; +(function (CompletionState) { + CompletionState[CompletionState["SUCCESS"] = 0] = "SUCCESS"; + CompletionState[CompletionState["FATAL_ERROR"] = 1] = "FATAL_ERROR"; + CompletionState[CompletionState["MANUALLY_ABORTED"] = 2] = "MANUALLY_ABORTED"; +})(CompletionState || (CompletionState = {})); +class ReleaseTool { + constructor(_config, _github, _githubToken, _projectRoot) { + this._config = _config; + this._github = _github; + this._githubToken = _githubToken; + this._projectRoot = _projectRoot; + /** Client for interacting with the Github API and the local Git command. */ + this._git = new GitClient(this._githubToken, { github: this._github }, this._projectRoot); + } + /** Runs the interactive release tool. */ + run() { + return tslib.__awaiter(this, void 0, void 0, function* () { + log(); + log(yellow('--------------------------------------------')); + log(yellow(' Angular Dev-Infra release staging script')); + log(yellow('--------------------------------------------')); + log(); + if (!(yield this._verifyNoUncommittedChanges()) || !(yield this._verifyRunningFromNextBranch())) { + return CompletionState.FATAL_ERROR; + } + const { owner, name } = this._github; + const repo = { owner, name, api: this._git.github }; + const releaseTrains = yield fetchActiveReleaseTrains(repo); + // Print the active release trains so that the caretaker can access + // the current project branching state without switching context. + yield printActiveReleaseTrains(releaseTrains, this._config); + const action = yield this._promptForReleaseAction(releaseTrains); + const previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision(); + try { + yield action.perform(); + } + catch (e) { + if (e instanceof UserAbortedReleaseActionError) { + return CompletionState.MANUALLY_ABORTED; + } + // Only print the error message and stack if the error is not a known fatal release + // action error (for which we print the error gracefully to the console with colors). + if (!(e instanceof FatalReleaseActionError) && e instanceof Error) { + console.error(e); + } + return CompletionState.FATAL_ERROR; + } + finally { + this._git.checkout(previousGitBranchOrRevision, true); + } + return CompletionState.SUCCESS; + }); + } + /** Prompts the caretaker for a release action that should be performed. */ + _promptForReleaseAction(activeTrains) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const choices = []; + // Find and instantiate all release actions which are currently valid. + for (let actionType of actions) { + if (yield actionType.isActive(activeTrains)) { + const action = new actionType(activeTrains, this._git, this._config, this._projectRoot); + choices.push({ name: yield action.getDescription(), value: action }); + } + } + info('Please select the type of release you want to perform.'); + const { releaseAction } = yield inquirer.prompt({ + name: 'releaseAction', + message: 'Please select an action:', + type: 'list', + choices, + }); + return releaseAction; + }); + } + /** + * Verifies that there are no uncommitted changes in the project. + * @returns a boolean indicating success or failure. + */ + _verifyNoUncommittedChanges() { + return tslib.__awaiter(this, void 0, void 0, function* () { + if (this._git.hasUncommittedChanges()) { + error(red(' ✘ There are changes which are not committed and should be discarded.')); + return false; + } + return true; + }); + } + /** + * Verifies that the next branch from the configured repository is checked out. + * @returns a boolean indicating success or failure. + */ + _verifyRunningFromNextBranch() { + return tslib.__awaiter(this, void 0, void 0, function* () { + const headSha = this._git.run(['rev-parse', 'HEAD']).stdout.trim(); + const { data } = yield this._git.github.repos.getBranch(Object.assign(Object.assign({}, this._git.remoteParams), { branch: nextBranchName })); + if (headSha !== data.commit.sha) { + error(red(' ✘ Running release tool from an outdated local branch.')); + error(red(` Please make sure you are running from the "${nextBranchName}" branch.`)); + return false; + } + return true; + }); + } +} + +/** + * @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 + */ +/** Yargs command builder for configuring the `ng-dev release publish` command. */ +function builder$7(argv) { + return addGithubTokenOption(argv); +} +/** Yargs command handler for staging a release. */ +function handler$7(args) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const config = getConfig(); + const releaseConfig = getReleaseConfig(config); + const projectDir = getRepoBaseDir(); + const task = new ReleaseTool(releaseConfig, config.github, args.githubToken, projectDir); + const result = yield task.run(); + switch (result) { + case CompletionState.FATAL_ERROR: + error(red(`Release action has been aborted due to fatal errors. See above.`)); + process.exitCode = 1; + break; + case CompletionState.MANUALLY_ABORTED: + info(yellow(`Release action has been manually aborted.`)); + break; + case CompletionState.SUCCESS: + info(green(`Release action has completed successfully.`)); + break; + } + }); +} +/** CLI command module for publishing a release. */ +const ReleasePublishCommandModule = { + builder: builder$7, + handler: handler$7, + command: 'publish', + describe: 'Publish new releases and configure version branches.', +}; + +/** + * @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 builder$8(args) { + return args + .positional('tagName', { + type: 'string', + demandOption: true, + description: 'Name of the NPM dist tag.', + }) + .positional('targetVersion', { + type: 'string', + demandOption: true, + description: 'Version to which the dist tag should be set.' + }); +} +/** Yargs command handler for building a release. */ +function handler$8(args) { + return tslib.__awaiter(this, void 0, void 0, function* () { + const { targetVersion: rawVersion, tagName } = args; + const { npmPackages, publishRegistry } = getReleaseConfig(); + const version = semver.parse(rawVersion); + if (version === null) { + error(red(`Invalid version specified (${rawVersion}). Unable to set NPM dist tag.`)); + process.exit(1); + } + const spinner = ora.call(undefined).start(); + debug(`Setting "${tagName}" NPM dist tag for release packages to v${version}.`); + for (const pkgName of npmPackages) { + spinner.text = `Setting NPM dist tag for "${pkgName}"`; + spinner.render(); + try { + yield setNpmTagForPackage(pkgName, tagName, version, publishRegistry); + debug(`Successfully set "${tagName}" NPM dist tag for "${pkgName}".`); + } + catch (e) { + spinner.stop(); + error(e); + error(red(` ✘ An error occurred while setting the NPM dist tag for "${pkgName}".`)); + process.exit(1); + } + } + spinner.stop(); + info(green(` ✓ Set NPM dist tag for all release packages.`)); + info(green(` ${bold(tagName)} will now point to ${bold(`v${version}`)}.`)); + }); +} +/** CLI command module for setting an NPM dist tag. */ +const ReleaseSetDistTagCommand = { + builder: builder$8, + handler: handler$8, + command: 'set-dist-tag ', + describe: 'Sets a given NPM dist tag for all release packages.', +}; + +/** + * @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 + */ +/** + * Log the environment variables expected by bazel for stamping. + * + * See the section on stamping in docs / BAZEL.md + * + * This script must be a NodeJS script in order to be cross-platform. + * See https://github.com/bazelbuild/bazel/issues/5958 + * Note: git operations, especially git status, take a long time inside mounted docker volumes + * in Windows or OSX hosts (https://github.com/docker/for-win/issues/188). + */ +function buildEnvStamp() { + console.info(`BUILD_SCM_BRANCH ${getCurrentBranch()}`); + console.info(`BUILD_SCM_COMMIT_SHA ${getCurrentSha()}`); + console.info(`BUILD_SCM_HASH ${getCurrentSha()}`); + console.info(`BUILD_SCM_LOCAL_CHANGES ${hasLocalChanges()}`); + console.info(`BUILD_SCM_USER ${getCurrentGitUser()}`); + console.info(`BUILD_SCM_VERSION ${getSCMVersion()}`); + process.exit(0); +} +/** Run the exec command and return the stdout as a trimmed string. */ +function exec$1(cmd) { + return exec(cmd).trim(); +} +/** Whether the repo has local changes. */ +function hasLocalChanges() { + return !!exec$1(`git status --untracked-files=no --porcelain`); +} +/** Get the version based on the most recent semver tag. */ +function getSCMVersion() { + const version = exec$1(`git describe --match [0-9]*.[0-9]*.[0-9]* --abbrev=7 --tags HEAD`); + return `${version.replace(/-([0-9]+)-g/, '+$1.sha-')}${(hasLocalChanges() ? '.with-local-changes' : '')}`; +} +/** Get the current SHA of HEAD. */ +function getCurrentSha() { + return exec$1(`git rev-parse HEAD`); +} +/** Get the currently checked out branch. */ +function getCurrentBranch() { + return exec$1(`git symbolic-ref --short HEAD`); +} +/** Get the current git user based on the git config. */ +function getCurrentGitUser() { + const userName = exec$1(`git config user.name`); + const userEmail = exec$1(`git config user.email`); + return `${userName} <${userEmail}>`; +} + +/** Build the parser for the release commands. */ +function buildReleaseParser(localYargs) { + return localYargs.help() + .strict() + .demandCommand() + .command(ReleasePublishCommandModule) + .command(ReleaseBuildCommandModule) + .command(ReleaseSetDistTagCommand) + .command('build-env-stamp', 'Build the environment stamping information', {}, () => buildEnvStamp()); +} + +/** + * @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 + */ +/** Gets the status of the specified file. Returns null if the file does not exist. */ +function getFileStatus(filePath) { + try { + return fs.statSync(filePath); + } + catch (_a) { + return null; + } +} +/** Ensures that the specified path uses forward slashes as delimiter. */ +function convertPathToForwardSlash(path) { + return path.replace(/\\/g, '/'); +} + +/** + * @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 + */ +/** + * Finds all module references in the specified source file. + * @param node Source file which should be parsed. + * @returns List of import specifiers in the source file. + */ +function getModuleReferences(node) { + const references = []; + const visitNode = (node) => { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined && ts.isStringLiteral(node.moduleSpecifier)) { + references.push(node.moduleSpecifier.text); + } + ts.forEachChild(node, visitNode); + }; + ts.forEachChild(node, visitNode); + return references; +} + +/** + * @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 + */ +/** Default extensions that the analyzer uses for resolving imports. */ +const DEFAULT_EXTENSIONS = ['ts', 'js', 'd.ts']; +/** + * Analyzer that can be used to detect import cycles within source files. It supports + * custom module resolution, source file caching and collects unresolved specifiers. + */ +class Analyzer { + constructor(resolveModuleFn, extensions = DEFAULT_EXTENSIONS) { + this.resolveModuleFn = resolveModuleFn; + this.extensions = extensions; + this._sourceFileCache = new Map(); + this.unresolvedModules = new Set(); + this.unresolvedFiles = new Map(); + } + /** Finds all cycles in the specified source file. */ + findCycles(sf, visited = new WeakSet(), path = []) { + const previousIndex = path.indexOf(sf); + // If the given node is already part of the current path, then a cycle has + // been found. Add the reference chain which represents the cycle to the results. + if (previousIndex !== -1) { + return [path.slice(previousIndex)]; + } + // If the node has already been visited, then it's not necessary to go check its edges + // again. Cycles would have been already detected and collected in the first check. + if (visited.has(sf)) { + return []; + } + path.push(sf); + visited.add(sf); + // Go through all edges, which are determined through import/exports, and collect cycles. + const result = []; + for (const ref of getModuleReferences(sf)) { + const targetFile = this._resolveImport(ref, sf.fileName); + if (targetFile !== null) { + result.push(...this.findCycles(this.getSourceFile(targetFile), visited, path.slice())); + } + } + return result; + } + /** Gets the TypeScript source file of the specified path. */ + getSourceFile(filePath) { + const resolvedPath = path.resolve(filePath); + if (this._sourceFileCache.has(resolvedPath)) { + return this._sourceFileCache.get(resolvedPath); + } + const fileContent = fs.readFileSync(resolvedPath, 'utf8'); + const sourceFile = ts.createSourceFile(resolvedPath, fileContent, ts.ScriptTarget.Latest, false); + this._sourceFileCache.set(resolvedPath, sourceFile); + return sourceFile; + } + /** Resolves the given import specifier with respect to the specified containing file path. */ + _resolveImport(specifier, containingFilePath) { + if (specifier.charAt(0) === '.') { + const resolvedPath = this._resolveFileSpecifier(specifier, containingFilePath); + if (resolvedPath === null) { + this._trackUnresolvedFileImport(specifier, containingFilePath); + } + return resolvedPath; + } + if (this.resolveModuleFn) { + const targetFile = this.resolveModuleFn(specifier); + if (targetFile !== null) { + const resolvedPath = this._resolveFileSpecifier(targetFile); + if (resolvedPath !== null) { + return resolvedPath; + } + } + } + this.unresolvedModules.add(specifier); + return null; + } + /** Tracks the given file import as unresolved. */ + _trackUnresolvedFileImport(specifier, originFilePath) { + if (!this.unresolvedFiles.has(originFilePath)) { + this.unresolvedFiles.set(originFilePath, [specifier]); + } + this.unresolvedFiles.get(originFilePath).push(specifier); + } + /** Resolves the given import specifier to the corresponding source file. */ + _resolveFileSpecifier(specifier, containingFilePath) { + const importFullPath = containingFilePath !== undefined ? path.join(path.dirname(containingFilePath), specifier) : specifier; + const stat = getFileStatus(importFullPath); + if (stat && stat.isFile()) { + return importFullPath; + } + for (const extension of this.extensions) { + const pathWithExtension = `${importFullPath}.${extension}`; + const stat = getFileStatus(pathWithExtension); + if (stat && stat.isFile()) { + return pathWithExtension; + } + } + // Directories should be considered last. TypeScript first looks for source files, then + // falls back to directories if no file with appropriate extension could be found. + if (stat && stat.isDirectory()) { + return this._resolveFileSpecifier(path.join(importFullPath, 'index')); + } + return null; + } +} + +/** + * @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 the configuration for the circular dependencies test. If the config cannot be + * loaded, an error will be printed and the process exists with a non-zero exit code. + */ +function loadTestConfig(configPath) { + const configBaseDir = path.dirname(configPath); + const resolveRelativePath = (relativePath) => path.resolve(configBaseDir, relativePath); + try { + const config = require(configPath); + if (!path.isAbsolute(config.baseDir)) { + config.baseDir = resolveRelativePath(config.baseDir); + } + if (!path.isAbsolute(config.goldenFile)) { + config.goldenFile = resolveRelativePath(config.goldenFile); + } + if (!path.isAbsolute(config.glob)) { + config.glob = resolveRelativePath(config.glob); + } + return config; + } + catch (e) { + error('Could not load test configuration file at: ' + configPath); + error(`Failed with: ${e.message}`); + process.exit(1); + } +} + +/** + * @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 + */ +/** + * Converts a list of reference chains to a JSON-compatible golden object. Reference chains + * by default use TypeScript source file objects. In order to make those chains printable, + * the source file objects are mapped to their relative file names. + */ +function convertReferenceChainToGolden(refs, baseDir) { + return refs + .map( + // Normalize cycles as the paths can vary based on which node in the cycle is visited + // first in the analyzer. The paths represent cycles. Hence we can shift nodes in a + // deterministic way so that the goldens don't change unnecessarily and cycle comparison + // is simpler. + chain => normalizeCircularDependency(chain.map(({ fileName }) => convertPathToForwardSlash(path.relative(baseDir, fileName))))) + // Sort cycles so that the golden doesn't change unnecessarily when cycles are detected + // in different order (e.g. new imports cause cycles to be detected earlier or later). + .sort(compareCircularDependency); +} +/** + * Compares the specified goldens and returns two lists that describe newly + * added circular dependencies, or fixed circular dependencies. + */ +function compareGoldens(actual, expected) { + const newCircularDeps = []; + const fixedCircularDeps = []; + actual.forEach(a => { + if (!expected.find(e => isSameCircularDependency(a, e))) { + newCircularDeps.push(a); + } + }); + expected.forEach(e => { + if (!actual.find(a => isSameCircularDependency(e, a))) { + fixedCircularDeps.push(e); + } + }); + return { newCircularDeps, fixedCircularDeps }; +} +/** + * Normalizes the a circular dependency by ensuring that the path starts with the first + * node in alphabetical order. Since the path array represents a cycle, we can make a + * specific node the first element in the path that represents the cycle. + * + * This method is helpful because the path of circular dependencies changes based on which + * file in the path has been visited first by the analyzer. e.g. Assume we have a circular + * dependency represented as: `A -> B -> C`. The analyzer will detect this cycle when it + * visits `A`. Though when a source file that is analyzed before `A` starts importing `B`, + * the cycle path will detected as `B -> C -> A`. This represents the same cycle, but is just + * different due to a limitation of using a data structure that can be written to a text-based + * golden file. + * + * To account for this non-deterministic behavior in goldens, we shift the circular + * dependency path to the first node based on alphabetical order. e.g. `A` will always + * be the first node in the path that represents the cycle. + */ +function normalizeCircularDependency(path) { + if (path.length <= 1) { + return path; + } + let indexFirstNode = 0; + let valueFirstNode = path[0]; + // Find a node in the cycle path that precedes all other elements + // in terms of alphabetical order. + for (let i = 1; i < path.length; i++) { + const value = path[i]; + if (value.localeCompare(valueFirstNode, 'en') < 0) { + indexFirstNode = i; + valueFirstNode = value; + } + } + // If the alphabetically first node is already at start of the path, just + // return the actual path as no changes need to be made. + if (indexFirstNode === 0) { + return path; + } + // Move the determined first node (as of alphabetical order) to the start of a new + // path array. The nodes before the first node in the old path are then concatenated + // to the end of the new path. This is possible because the path represents a cycle. + return [...path.slice(indexFirstNode), ...path.slice(0, indexFirstNode)]; +} +/** Checks whether the specified circular dependencies are equal. */ +function isSameCircularDependency(actual, expected) { + if (actual.length !== expected.length) { + return false; + } + for (let i = 0; i < actual.length; i++) { + if (actual[i] !== expected[i]) { + return false; + } + } + return true; +} +/** + * Compares two circular dependencies by respecting the alphabetic order of nodes in the + * cycle paths. The first nodes which don't match in both paths are decisive on the order. + */ +function compareCircularDependency(a, b) { + // Go through nodes in both cycle paths and determine whether `a` should be ordered + // before `b`. The first nodes which don't match decide on the order. + for (let i = 0; i < Math.min(a.length, b.length); i++) { + const compareValue = a[i].localeCompare(b[i], 'en'); + if (compareValue !== 0) { + return compareValue; + } + } + // If all nodes are equal in the cycles, the order is based on the length of both cycles. + return a.length - b.length; +} + +/** + * @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 tsCircularDependenciesBuilder(localYargs) { + return localYargs.help() + .strict() + .demandCommand() + .option('config', { type: 'string', demandOption: true, description: 'Path to the configuration file.' }) + .option('warnings', { type: 'boolean', description: 'Prints all warnings.' }) + .command('check', 'Checks if the circular dependencies have changed.', args => args, argv => { + const { config: configArg, warnings } = argv; + const configPath = path.isAbsolute(configArg) ? configArg : path.resolve(configArg); + const config = loadTestConfig(configPath); + process.exit(main(false, config, !!warnings)); + }) + .command('approve', 'Approves the current circular dependencies.', args => args, argv => { + const { config: configArg, warnings } = argv; + const configPath = path.isAbsolute(configArg) ? configArg : path.resolve(configArg); + const config = loadTestConfig(configPath); + process.exit(main(true, config, !!warnings)); + }); +} +/** + * Runs the ts-circular-dependencies tool. + * @param approve Whether the detected circular dependencies should be approved. + * @param config Configuration for the current circular dependencies test. + * @param printWarnings Whether warnings should be printed out. + * @returns Status code. + */ +function main(approve, config, printWarnings) { + const { baseDir, goldenFile, glob: glob$1, resolveModule, approveCommand } = config; + const analyzer = new Analyzer(resolveModule); + const cycles = []; + const checkedNodes = new WeakSet(); + glob.sync(glob$1, { absolute: true }).forEach(filePath => { + const sourceFile = analyzer.getSourceFile(filePath); + cycles.push(...analyzer.findCycles(sourceFile, checkedNodes)); + }); + const actual = convertReferenceChainToGolden(cycles, baseDir); + info(green(` Current number of cycles: ${yellow(cycles.length.toString())}`)); + if (approve) { + fs.writeFileSync(goldenFile, JSON.stringify(actual, null, 2)); + info(green('✅ Updated golden file.')); + return 0; + } + else if (!fs.existsSync(goldenFile)) { + error(red(`❌ Could not find golden file: ${goldenFile}`)); + return 1; + } + const warningsCount = analyzer.unresolvedFiles.size + analyzer.unresolvedModules.size; + // By default, warnings for unresolved files or modules are not printed. This is because + // it's common that third-party modules are not resolved/visited. Also generated files + // from the View Engine compiler (i.e. factories, summaries) cannot be resolved. + if (printWarnings && warningsCount !== 0) { + info(yellow('⚠ The following imports could not be resolved:')); + Array.from(analyzer.unresolvedModules).sort().forEach(specifier => info(` • ${specifier}`)); + analyzer.unresolvedFiles.forEach((value, key) => { + info(` • ${getRelativePath(baseDir, key)}`); + value.sort().forEach(specifier => info(` ${specifier}`)); + }); + } + else { + info(yellow(`⚠ ${warningsCount} imports could not be resolved.`)); + info(yellow(` Please rerun with "--warnings" to inspect unresolved imports.`)); + } + const expected = JSON.parse(fs.readFileSync(goldenFile, 'utf8')); + const { fixedCircularDeps, newCircularDeps } = compareGoldens(actual, expected); + const isMatching = fixedCircularDeps.length === 0 && newCircularDeps.length === 0; + if (isMatching) { + info(green('✅ Golden matches current circular dependencies.')); + return 0; + } + error(red('❌ Golden does not match current circular dependencies.')); + if (newCircularDeps.length !== 0) { + error(yellow(` New circular dependencies which are not allowed:`)); + newCircularDeps.forEach(c => error(` • ${convertReferenceChainToString(c)}`)); + error(); + } + if (fixedCircularDeps.length !== 0) { + error(yellow(` Fixed circular dependencies that need to be removed from the golden:`)); + fixedCircularDeps.forEach(c => error(` • ${convertReferenceChainToString(c)}`)); + info(yellow(`\n Total: ${newCircularDeps.length} new cycle(s), ${fixedCircularDeps.length} fixed cycle(s). \n`)); + if (approveCommand) { + info(yellow(` Please approve the new golden with: ${approveCommand}`)); + } + else { + info(yellow(` Please update the golden. The following command can be ` + + `run: yarn ts-circular-deps approve ${getRelativePath(process.cwd(), goldenFile)}.`)); + } + } + return 1; +} +/** Gets the specified path relative to the base directory. */ +function getRelativePath(baseDir, path$1) { + return convertPathToForwardSlash(path.relative(baseDir, path$1)); +} +/** Converts the given reference chain to its string representation. */ +function convertReferenceChainToString(chain) { + return chain.join(' → '); +} + +/** + * @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 + */ +yargs.scriptName('ng-dev') + .middleware(captureLogOutputForCommand) + .demandCommand() + .recommendCommands() + .command('commit-message ', '', buildCommitMessageParser) + .command('format ', '', buildFormatParser) + .command('pr ', '', buildPrParser) + .command('pullapprove ', '', buildPullapproveParser) + .command('release ', '', buildReleaseParser) + .command('ts-circular-deps ', '', tsCircularDependenciesBuilder) + .command('caretaker ', '', buildCaretakerParser) + .command('ngbot ', false, buildNgbotParser) + .wrap(120) + .strict() + .parse(); diff --git a/dev-infra/ngbot/cli.ts b/dev-infra/ngbot/cli.ts index 9a881ee92e..e03b57926f 100644 --- a/dev-infra/ngbot/cli.ts +++ b/dev-infra/ngbot/cli.ts @@ -13,7 +13,3 @@ export function buildNgbotParser(localYargs: yargs.Argv) { return localYargs.help().strict().demandCommand().command( 'verify', 'Verify the NgBot config', {}, () => verify()); } - -if (require.main === module) { - buildNgbotParser(yargs).parse(); -} diff --git a/dev-infra/pr/cli.ts b/dev-infra/pr/cli.ts index fb080ed5c2..d05b3069e6 100644 --- a/dev-infra/pr/cli.ts +++ b/dev-infra/pr/cli.ts @@ -28,7 +28,3 @@ export function buildPrParser(localYargs: yargs.Argv) { buildRebaseCommand, handleRebaseCommand) .command(CheckoutCommandModule); } - -if (require.main === module) { - buildPrParser(yargs).parse(); -} diff --git a/dev-infra/pr/common/checkout-pr.ts b/dev-infra/pr/common/checkout-pr.ts index dcb7be2e68..7792eb6ca0 100644 --- a/dev-infra/pr/common/checkout-pr.ts +++ b/dev-infra/pr/common/checkout-pr.ts @@ -9,8 +9,8 @@ import {types as graphQLTypes} from 'typed-graphqlify'; import {info} from '../../utils/console'; -import {GitClient} from '../../utils/git'; import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls'; +import {GitClient} from '../../utils/git/index'; import {getPr} from '../../utils/github'; /* GraphQL schema for the response body for a pending PR. */ diff --git a/dev-infra/pr/discover-new-conflicts/index.ts b/dev-infra/pr/discover-new-conflicts/index.ts index c59f1a0698..d73c329b2d 100644 --- a/dev-infra/pr/discover-new-conflicts/index.ts +++ b/dev-infra/pr/discover-new-conflicts/index.ts @@ -11,7 +11,7 @@ import {types as graphQLTypes} from 'typed-graphqlify'; import {getConfig, NgDevConfig} from '../../utils/config'; import {error, info} from '../../utils/console'; -import {GitClient} from '../../utils/git'; +import {GitClient} from '../../utils/git/index'; import {getPendingPrs} from '../../utils/github'; import {exec} from '../../utils/shelljs'; diff --git a/dev-infra/pr/merge/BUILD.bazel b/dev-infra/pr/merge/BUILD.bazel index 52865f0a23..b252fddc1f 100644 --- a/dev-infra/pr/merge/BUILD.bazel +++ b/dev-infra/pr/merge/BUILD.bazel @@ -8,6 +8,9 @@ ts_library( exclude = ["**/*.spec.ts"], ), module_name = "@angular/dev-infra-private/pr/merge", + # prodmode target must be set to es5 as the Ora class, which is extended is not a proper + # prototyped object. + prodmode_target = "es5", visibility = ["//dev-infra:__subpackages__"], deps = [ "//dev-infra/commit-message", diff --git a/dev-infra/pr/merge/index.ts b/dev-infra/pr/merge/index.ts index d6d28a6619..f1dff5031e 100644 --- a/dev-infra/pr/merge/index.ts +++ b/dev-infra/pr/merge/index.ts @@ -9,9 +9,9 @@ import {getConfig, getRepoBaseDir} from '../../utils/config'; import {error, green, info, promptConfirm, red, yellow} from '../../utils/console'; -import {GitClient} from '../../utils/git'; import {GithubApiRequestError} from '../../utils/git/github'; import {GITHUB_TOKEN_GENERATE_URL} from '../../utils/git/github-urls'; +import {GitClient} from '../../utils/git/index'; import {loadAndValidateConfig, MergeConfigWithRemote} from './config'; import {MergeResult, MergeStatus, PullRequestMergeTask} from './task'; diff --git a/dev-infra/pr/merge/pull-request.ts b/dev-infra/pr/merge/pull-request.ts index 40e1d646a9..8d8e667969 100644 --- a/dev-infra/pr/merge/pull-request.ts +++ b/dev-infra/pr/merge/pull-request.ts @@ -8,7 +8,7 @@ import * as Octokit from '@octokit/rest'; -import {GitClient} from '../../utils/git'; +import {GitClient} from '../../utils/git/index'; import {PullRequestFailure} from './failures'; import {matchesPattern} from './string-pattern'; diff --git a/dev-infra/pr/merge/strategies/api-merge.ts b/dev-infra/pr/merge/strategies/api-merge.ts index 42afdd1c6f..4268df8580 100644 --- a/dev-infra/pr/merge/strategies/api-merge.ts +++ b/dev-infra/pr/merge/strategies/api-merge.ts @@ -10,7 +10,7 @@ import {PullsListCommitsResponse, PullsMergeParams} from '@octokit/rest'; import {prompt} from 'inquirer'; import {parseCommitMessage} from '../../../commit-message/parse'; -import {GitClient} from '../../../utils/git'; +import {GitClient} from '../../../utils/git/index'; import {GithubApiMergeMethod} from '../config'; import {PullRequestFailure} from '../failures'; import {PullRequest} from '../pull-request'; diff --git a/dev-infra/pr/merge/strategies/strategy.ts b/dev-infra/pr/merge/strategies/strategy.ts index 2e1ff24d26..52fced888f 100644 --- a/dev-infra/pr/merge/strategies/strategy.ts +++ b/dev-infra/pr/merge/strategies/strategy.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {GitClient} from '../../../utils/git'; +import {GitClient} from '../../../utils/git/index'; import {PullRequestFailure} from '../failures'; import {PullRequest} from '../pull-request'; diff --git a/dev-infra/pr/merge/task.ts b/dev-infra/pr/merge/task.ts index 42ca89898f..b42f532b86 100644 --- a/dev-infra/pr/merge/task.ts +++ b/dev-infra/pr/merge/task.ts @@ -7,7 +7,7 @@ */ import {promptConfirm} from '../../utils/console'; -import {GitClient, GitCommandError} from '../../utils/git'; +import {GitClient, GitCommandError} from '../../utils/git/index'; import {MergeConfigWithRemote} from './config'; import {PullRequestFailure} from './failures'; diff --git a/dev-infra/pr/rebase/index.ts b/dev-infra/pr/rebase/index.ts index 7aa63e6290..974a0f0435 100644 --- a/dev-infra/pr/rebase/index.ts +++ b/dev-infra/pr/rebase/index.ts @@ -10,8 +10,8 @@ import {types as graphQLTypes} from 'typed-graphqlify'; import {getConfig, NgDevConfig} from '../../utils/config'; import {error, info, promptConfirm} from '../../utils/console'; -import {GitClient} from '../../utils/git'; import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls'; +import {GitClient} from '../../utils/git/index'; import {getPr} from '../../utils/github'; /* GraphQL schema for the response body for each pending PR. */ diff --git a/dev-infra/pullapprove/cli.ts b/dev-infra/pullapprove/cli.ts index 483e94276a..f3fa0b083b 100644 --- a/dev-infra/pullapprove/cli.ts +++ b/dev-infra/pullapprove/cli.ts @@ -13,7 +13,3 @@ export function buildPullapproveParser(localYargs: yargs.Argv) { return localYargs.help().strict().demandCommand().command( 'verify', 'Verify the pullapprove config', {}, () => verify()); } - -if (require.main === module) { - buildPullapproveParser(yargs).parse(); -} diff --git a/dev-infra/release/cli.ts b/dev-infra/release/cli.ts index 988b346437..6281e4ab88 100644 --- a/dev-infra/release/cli.ts +++ b/dev-infra/release/cli.ts @@ -24,7 +24,3 @@ export function buildReleaseParser(localYargs: yargs.Argv) { 'build-env-stamp', 'Build the environment stamping information', {}, () => buildEnvStamp()); } - -if (require.main === module) { - buildReleaseParser(yargs).parse(); -} diff --git a/dev-infra/release/publish/actions.ts b/dev-infra/release/publish/actions.ts index 2bfbbb44b9..507498b29c 100644 --- a/dev-infra/release/publish/actions.ts +++ b/dev-infra/release/publish/actions.ts @@ -301,7 +301,7 @@ export abstract class ReleaseAction { return new Promise((resolve, reject) => { debug(`Waiting for pull request #${id} to be merged.`); - const spinner = ora().start(`Waiting for pull request #${id} to be merged.`); + const spinner = ora.call(undefined).start(`Waiting for pull request #${id} to be merged.`); const intervalId = setInterval(async () => { const prState = await getPullRequestState(this.git, id); if (prState === 'merged') { @@ -517,7 +517,7 @@ export abstract class ReleaseAction { /** Publishes the given built package to NPM with the specified NPM dist tag. */ private async _publishBuiltPackageToNpm(pkg: BuiltPackage, npmDistTag: string) { debug(`Starting publish of "${pkg.name}".`); - const spinner = ora().start(`Publishing "${pkg.name}"`); + const spinner = ora.call(undefined).start(`Publishing "${pkg.name}"`); try { await runNpmPublish(pkg.outputPath, npmDistTag, this.config.publishRegistry); diff --git a/dev-infra/release/publish/cli.ts b/dev-infra/release/publish/cli.ts index 5a61dd8072..171f2edb28 100644 --- a/dev-infra/release/publish/cli.ts +++ b/dev-infra/release/publish/cli.ts @@ -11,7 +11,7 @@ import {Arguments, Argv, CommandModule} from 'yargs'; import {getConfig, getRepoBaseDir} from '../../utils/config'; import {error, green, info, red, yellow} from '../../utils/console'; import {addGithubTokenOption} from '../../utils/git/github-yargs'; -import {getReleaseConfig} from '../config'; +import {getReleaseConfig} from '../config/index'; import {CompletionState, ReleaseTool} from './index'; diff --git a/dev-infra/release/publish/external-commands.ts b/dev-infra/release/publish/external-commands.ts index 8b27c14f31..49698607e6 100644 --- a/dev-infra/release/publish/external-commands.ts +++ b/dev-infra/release/publish/external-commands.ts @@ -54,7 +54,7 @@ export async function invokeSetNpmDistCommand(npmDistTag: string, version: semve * packages for the currently checked out branch. */ export async function invokeReleaseBuildCommand(): Promise { - const spinner = ora().start('Building release output.'); + const spinner = ora.call(undefined).start('Building release output.'); try { // Since we expect JSON to be printed from the `ng-dev release build` command, // we spawn the process in silent mode. We have set up an Ora progress spinner. diff --git a/dev-infra/release/set-dist-tag/cli.ts b/dev-infra/release/set-dist-tag/cli.ts index 29cc0f2dd2..06c5e7e2a3 100644 --- a/dev-infra/release/set-dist-tag/cli.ts +++ b/dev-infra/release/set-dist-tag/cli.ts @@ -46,7 +46,7 @@ async function handler(args: Arguments) { process.exit(1); } - const spinner = ora().start(); + const spinner = ora.call(undefined).start(); debug(`Setting "${tagName}" NPM dist tag for release packages to v${version}.`); for (const pkgName of npmPackages) { diff --git a/dev-infra/ts-circular-dependencies/index.ts b/dev-infra/ts-circular-dependencies/index.ts index e76ad4d7e1..2800c31ba4 100644 --- a/dev-infra/ts-circular-dependencies/index.ts +++ b/dev-infra/ts-circular-dependencies/index.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node /** * @license * Copyright Google LLC All Rights Reserved. @@ -13,12 +12,12 @@ import {isAbsolute, relative, resolve} from 'path'; import * as ts from 'typescript'; import * as yargs from 'yargs'; -import {green, info, error, red, yellow} from '../utils/console'; +import {error, green, info, red, yellow} from '../utils/console'; import {Analyzer, ReferenceChain} from './analyzer'; -import {compareGoldens, convertReferenceChainToGolden, Golden} from './golden'; +import {CircularDependenciesTestConfig, loadTestConfig} from './config'; import {convertPathToForwardSlash} from './file_system'; -import {loadTestConfig, CircularDependenciesTestConfig} from './config'; +import {compareGoldens, convertReferenceChainToGolden, Golden} from './golden'; export function tsCircularDependenciesBuilder(localYargs: yargs.Argv) { @@ -134,7 +133,3 @@ function getRelativePath(baseDir: string, path: string) { function convertReferenceChainToString(chain: ReferenceChain) { return chain.join(' → '); } - -if (require.main === module) { - tsCircularDependenciesBuilder(yargs).parse(); -} diff --git a/dev-infra/utils/BUILD.bazel b/dev-infra/utils/BUILD.bazel index 24dc637c6d..122ad9fdf2 100644 --- a/dev-infra/utils/BUILD.bazel +++ b/dev-infra/utils/BUILD.bazel @@ -7,6 +7,9 @@ ts_library( "git/*.ts", ]), module_name = "@angular/dev-infra-private/utils", + # prodmode target must be set to es5 as the Octokit class, which is extended is not a proper + # prototyped object. + prodmode_target = "es5", visibility = ["//dev-infra:__subpackages__"], deps = [ "@npm//@octokit/graphql", diff --git a/dev-infra/utils/github.ts b/dev-infra/utils/github.ts index 7e187a24a4..6b79440124 100644 --- a/dev-infra/utils/github.ts +++ b/dev-infra/utils/github.ts @@ -8,7 +8,7 @@ import {params, types} from 'typed-graphqlify'; -import {GitClient} from './git'; +import {GitClient} from './git/index'; /** Get a PR from github */ export async function getPr(prSchema: PrSchema, prNumber: number, git: GitClient) { diff --git a/package.json b/package.json index c425e2a2ad..c4417a9cf7 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,9 @@ "public-api:update": "node goldens/public-api/manage.js accept", "symbol-extractor:check": "node tools/symbol-extractor/run_all_symbols_extractor_tests.js test", "symbol-extractor:update": "node tools/symbol-extractor/run_all_symbols_extractor_tests.js accept", - "ts-circular-deps": "ts-node --transpile-only -- dev-infra/ts-circular-dependencies/index.ts --config ./packages/circular-deps-test.conf.js", - "ts-circular-deps:check": "yarn -s ts-circular-deps check", - "ts-circular-deps:approve": "yarn -s ts-circular-deps approve", - "ng-dev": "ts-node --transpile-only -- dev-infra/cli.ts" + "ts-circular-deps:check": "yarn -s ng-dev ts-circular-deps check --config ./packages/circular-deps-test.conf.js", + "ts-circular-deps:approve": "yarn -s ng-dev ts-circular-deps approve --config ./packages/circular-deps-test.conf.js", + "ng-dev": "node dev-infra/ng-dev" }, "// 1": "dependencies are used locally and by bazel", "dependencies": { diff --git a/tslint.json b/tslint.json index 1ed2b50170..f2ff3e44a3 100644 --- a/tslint.json +++ b/tslint.json @@ -98,7 +98,8 @@ "./modules/benchmarks_external/**/*", // Ignore zone.js directory // TODO(JiaLiPassion): add zone.js back later - "./packages/zone.js/**/*" + "./packages/zone.js/**/*", + "./dev-infra/ng-dev.js" ] } }