#!/usr/bin/env node '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 to better understand the git actions occuring. Depending on the command being // executed, this debugging information should be logged at different logging levels. var printFn = (!GitClient.LOG_COMMANDS || options.stdio === 'ignore') ? debug : info; // 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. 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(); }); }); }; /** Whether verbose logging of Git actions should be used. */ GitClient.LOG_COMMANDS = true; 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`; } /** The BaseModule to extend modules for caretaker checks from. */ class BaseModule { constructor(git, config) { this.git = git; this.config = config; /** The data for the module. */ this.data = this.retrieveData(); } } /** * @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 CiModule extends BaseModule { retrieveData() { return tslib.__awaiter(this, void 0, void 0, function* () { const gitRepoWithApi = Object.assign({ api: this.git.github }, this.git.remoteConfig); const releaseTrains = yield fetchActiveReleaseTrains(gitRepoWithApi); const ciResultPromises = Object.entries(releaseTrains).map(([trainName, train]) => tslib.__awaiter(this, void 0, void 0, function* () { if (train === null) { return { active: false, name: trainName, label: '', status: 'not found', }; } return { active: true, name: train.branchName, label: `${trainName} (${train.branchName})`, status: yield this.getBranchStatusFromCi(train.branchName), }; })); return yield Promise.all(ciResultPromises); }); } printToTerminal() { return tslib.__awaiter(this, void 0, void 0, function* () { const data = yield this.data; const minLabelLength = Math.max(...data.map(result => result.label.length)); info.group(bold(`CI`)); data.forEach(result => { if (result.active === false) { debug(`No active release train for ${result.name}`); return; } const label = result.label.padEnd(minLabelLength); if (result.status === 'not found') { info(`${result.name} was not found on CircleCI`); } else if (result.status === 'success') { info(`${label} ✅`); } else { info(`${label} ❌`); } }); info.groupEnd(); info(); }); } /** Get the CI status of a given branch from CircleCI. */ getBranchStatusFromCi(branch) { return tslib.__awaiter(this, void 0, void 0, function* () { const { owner, name } = this.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 result.includes('passing') ? 'success' : 'failed'; } return 'not found'; }); } } /** * @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 G3Module extends BaseModule { retrieveData() { return tslib.__awaiter(this, void 0, void 0, function* () { const toCopyToG3 = this.getG3FileIncludeAndExcludeLists(); const latestSha = this.getLatestShas(); if (toCopyToG3 === null || latestSha === null) { return; } return this.getDiffStats(latestSha.g3, latestSha.master, toCopyToG3.include, toCopyToG3.exclude); }); } printToTerminal() { return tslib.__awaiter(this, void 0, void 0, function* () { const stats = yield this.data; if (!stats) { return; } info.group(bold('g3 branch check')); if (stats.files === 0) { info(`${stats.commits} commits between g3 and master`); info('✅ No sync is needed at this time'); } else { info(`${stats.files} files changed, ${stats.insertions} insertions(+), ${stats.deletions} ` + `deletions(-) from ${stats.commits} commits will be included in the next sync`); } info.groupEnd(); info(); }); } /** Fetch and retrieve the latest sha for a specific branch. */ getShaForBranchLatest(branch) { const { owner, name } = this.git.remoteConfig; /** The result fo the fetch command. */ const fetchResult = this.git.runGraceful(['fetch', '-q', `https://github.com/${owner}/${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 null; } return this.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. */ getDiffStats(g3Ref, masterRef, includeFiles, excludeFiles) { /** 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(this.git.run(['rev-list', '--count', `${g3Ref}..${masterRef}`]).stdout, 10); // Get the numstat information between master and g3 this.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.trim().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 (this.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. */ checkMatchAgainstIncludeAndExclude(file, includes, excludes) { return (multimatch.call(undefined, file, includes).length >= 1 && multimatch.call(undefined, file, excludes).length === 0); } getG3FileIncludeAndExcludeLists() { var _a, _b, _c, _d; const angularRobotFilePath = path.join(getRepoBaseDir(), '.github/angular-robot.yml'); if (!fs.existsSync(angularRobotFilePath)) { debug('No angular robot configuration file exists, skipping.'); return null; } /** 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 include = ((_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 exclude = ((_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 (include.length === 0 && exclude.length === 0) { debug('No g3Status include or exclude lists are defined in the angular robot configuration'); return null; } return { include, exclude }; } getLatestShas() { /** The latest sha for the g3 branch. */ const g3 = this.getShaForBranchLatest('g3'); /** The latest sha for the master branch. */ const master = this.getShaForBranchLatest('master'); if (g3 === null || master === null) { debug('Either the g3 or master was unable to be retrieved'); return null; } return { g3, master }; } } /** * @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 fragment for a result from Github's api for a Github query. */ const GithubQueryResultFragment = { issueCount: typedGraphqlify.types.number, nodes: [Object.assign({}, typedGraphqlify.onUnion({ PullRequest: { url: typedGraphqlify.types.string, }, Issue: { url: typedGraphqlify.types.string, }, }))], }; /** * Cap the returned issues in the queries to an arbitrary 20. 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; class GithubQueriesModule extends BaseModule { retrieveData() { var _a; return tslib.__awaiter(this, void 0, void 0, function* () { // Non-null assertion is used here as the check for undefined immediately follows to confirm the // assertion. Typescript's type filtering does not seem to work as needed to understand // whether githubQueries is undefined or not. let queries = (_a = this.config.caretaker) === null || _a === void 0 ? void 0 : _a.githubQueries; if (queries === undefined || queries.length === 0) { debug('No github queries defined in the configuration, skipping'); return; } /** The results of the generated github query. */ const queryResult = yield this.git.github.graphql.query(this.buildGraphqlQuery(queries)); const results = Object.values(queryResult); const { owner, name: repo } = this.git.remoteConfig; return results.map((result, i) => { return { queryName: queries[i].name, count: result.issueCount, queryUrl: encodeURI(`https://github.com/${owner}/${repo}/issues?q=${queries[i].query}`), matchedUrls: result.nodes.map(node => node.url) }; }); }); } /** Build a Graphql query statement for the provided queries. */ buildGraphqlQuery(queries) { /** The query object for graphql. */ const graphQlQuery = {}; const { owner, name: repo } = this.git.remoteConfig; /** The Github search filter for the configured repository. */ const repoFilter = `repo:${owner}/${repo}`; 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, '\\"')}"`, }, Object.assign({}, GithubQueryResultFragment)); }); return graphQlQuery; } printToTerminal() { return tslib.__awaiter(this, void 0, void 0, function* () { const queryResults = yield this.data; if (!queryResults) { return; } info.group(bold('Github Tasks')); const minQueryNameLength = Math.max(...queryResults.map(result => result.queryName.length)); for (const queryResult of queryResults) { info(`${queryResult.queryName.padEnd(minQueryNameLength)} ${queryResult.count}`); if (queryResult.count > 0) { info.group(queryResult.queryUrl); queryResult.matchedUrls.forEach(url => info(`- ${url}`)); if (queryResult.count > MAX_RETURNED_ISSUES) { info(`... ${queryResult.count - MAX_RETURNED_ISSUES} additional matches`); } info.groupEnd(); } } info.groupEnd(); 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 */ /** List of services Angular relies on. */ const services = [ { url: 'https://status.us-west-1.saucelabs.com/api/v2/status.json', name: 'Saucelabs', }, { url: 'https://status.npmjs.org/api/v2/status.json', name: 'Npm', }, { url: 'https://status.circleci.com/api/v2/status.json', name: 'CircleCi', }, { url: 'https://www.githubstatus.com/api/v2/status.json', name: 'Github', }, ]; class ServicesModule extends BaseModule { retrieveData() { return tslib.__awaiter(this, void 0, void 0, function* () { return Promise.all(services.map(service => this.getStatusFromStandardApi(service))); }); } printToTerminal() { return tslib.__awaiter(this, void 0, void 0, function* () { const statuses = yield this.data; const serviceNameMinLength = Math.max(...statuses.map(service => service.name.length)); info.group(bold('Service Statuses')); for (const status of statuses) { const name = status.name.padEnd(serviceNameMinLength); if (status.status === 'passing') { info(`${name} ✅`); } else { info.group(`${name} ❌ (Updated: ${status.lastUpdated.toLocaleString()})`); info(` Details: ${status.description}`); info.groupEnd(); } } info.groupEnd(); info(); }); } /** Retrieve the status information for a service which uses a standard API response. */ getStatusFromStandardApi(service) { return tslib.__awaiter(this, void 0, void 0, function* () { const result = yield fetch(service.url).then(result => result.json()); const status = result.status.indicator === 'none' ? 'passing' : 'failing'; return { name: service.name, 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 */ /** List of modules checked for the caretaker check command. */ const moduleList = [ GithubQueriesModule, ServicesModule, CiModule, G3Module, ]; /** 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); // Prevent logging of the git commands being executed during the check. GitClient.LOG_COMMANDS = false; /** List of instances of Caretaker Check modules */ const caretakerCheckModules = moduleList.map(module => new module(git, config)); // Module's `data` is casted as Promise because the data types of the `module`'s `data` // promises do not match typings, however our usage here is only to determine when the promise // resolves. yield Promise.all(caretakerCheckModules.map(module => module.data)); for (const module of caretakerCheckModules) { yield module.printToTerminal(); } }); } /** * @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', 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' }) .positional('file', { type: 'string' }) .positional('source', { type: 'string' }); } /** Handles the command. */ function handler$1({ fileEnvVariable, file, source }) { return tslib.__awaiter(this, void 0, void 0, function* () { // File and source are provided as command line parameters if (file !== undefined) { restoreCommitMessage(file, source); return; } // File and source are provided as values held in an environment variable. if (fileEnvVariable !== undefined) { const [fileFromEnv, sourceFromEnv] = (process.env[fileEnvVariable] || '').split(' '); if (!fileFromEnv) { throw new Error(`Provided environment variable "${fileEnvVariable}" was not found.`); } restoreCommitMessage(fileFromEnv, sourceFromEnv); return; } throw new Error('No file path and commit message source provide. Provide values via positional command ' + 'arguments, or via the --file-env-variable flag'); }); } /** yargs command module describing the command. */ const RestoreCommitMessageModule = { handler: handler$1, builder: builder$1, command: 'restore-commit-message-draft [file] [source]', // 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), }; } /** Retrieve and parse each commit message in a provide range. */ function parseCommitMessagesForRange(range) { /** A random number used as a 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}`; // 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}`); } return result // Separate the commits from a single string into individual commits. .split(randomValueSeparator) // Remove extra space before and after each commit message. .map(l => l.trim()) // Remove any superfluous lines which remain from the split. .filter(line => !!line) // Parse each commit message. .map(commit => parseCommitMessage(commit)); } /** * @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 = typeof commitMsg === 'string' ? parseCommitMessage(commitMsg) : 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 = (commit) => !commit.isFixup; // Extracts commit header (first line of commit message). const extractCommitHeader = (commit) => commit.header; /** Validate all commits in a provided git commit range. */ function validateCommitRange(range) { /** A list of tuples of the commit header string and a list of error messages for the commit. */ const errors = []; /** A list of parsed commit messages from the range. */ const commits = parseCommitMessagesForRange(range); info(`Examining ${commits.length} commit(s) in the provided range: ${range}`); /** * Whether all commits in the range are valid, commits are allowed to be fixup commits for other * commits in the provided commit range. */ const allCommitsInRangeValid = commits.every((commit, i) => { const options = { disallowSquash: true, nonFixupCommitHeaders: isNonFixup(commit) ? undefined : commits.slice(0, i).filter(isNonFixup).map(extractCommitHeader) }; const { valid, errors: localErrors } = validateCommitMessage(commit, 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 */ /** Loads and validates the merge configuration. */ function loadAndValidateConfig(config, api) { return tslib.__awaiter(this, void 0, void 0, function () { var mergeConfig, errors; return tslib.__generator(this, function (_a) { switch (_a.label) { case 0: if (config.merge === undefined) { return [2 /*return*/, { errors: ['No merge configuration found. Set the `merge` configuration.'] }]; } if (typeof config.merge !== 'function') { return [2 /*return*/, { errors: ['Expected merge configuration to be defined lazily through a function.'] }]; } return [4 /*yield*/, config.merge(api)]; case 1: mergeConfig = _a.sent(); errors = validateMergeConfig(mergeConfig); if (errors.length) { return [2 /*return*/, { errors: errors }]; } return [2 /*return*/, { config: mergeConfig }]; } }); }); } /** Validates the specified configuration. Returns a list of failure messages. */ function validateMergeConfig(config) { var errors = []; if (!config.labels) { errors.push('No label configuration.'); } else if (!Array.isArray(config.labels)) { errors.push('Label configuration needs to be an array.'); } if (!config.claSignedLabel) { errors.push('No CLA signed label configured.'); } if (!config.mergeReadyLabel) { errors.push('No merge ready label configured.'); } if (config.githubApiMerge === undefined) { errors.push('No explicit choice of merge strategy. Please set `githubApiMerge`.'); } return errors; } /** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** Checks whether the specified value matches the given pattern. */ function matchesPattern(value, pattern) { return typeof pattern === 'string' ? value === pattern : pattern.test(value); } /** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** * Unique error that can be thrown in the merge configuration if an * invalid branch is targeted. */ var InvalidTargetBranchError = /** @class */ (function () { function InvalidTargetBranchError(failureMessage) { this.failureMessage = failureMessage; } return InvalidTargetBranchError; }()); /** * Unique error that can be thrown in the merge configuration if an * invalid label has been applied to a pull request. */ var InvalidTargetLabelError = /** @class */ (function () { function InvalidTargetLabelError(failureMessage) { this.failureMessage = failureMessage; } return InvalidTargetLabelError; }()); /** Gets the target label from the specified pull request labels. */ function getTargetLabelFromPullRequest(config, labels) { var e_1, _a; var _loop_1 = function (label) { var match = config.labels.find(function (_a) { var pattern = _a.pattern; return matchesPattern(label, pattern); }); if (match !== undefined) { return { value: match }; } }; try { for (var labels_1 = tslib.__values(labels), labels_1_1 = labels_1.next(); !labels_1_1.done; labels_1_1 = labels_1.next()) { var label = labels_1_1.value; var state_1 = _loop_1(label); if (typeof state_1 === "object") return state_1.value; } } catch (e_1_1) { e_1 = { error: e_1_1 }; } finally { try { if (labels_1_1 && !labels_1_1.done && (_a = labels_1.return)) _a.call(labels_1); } finally { if (e_1) throw e_1.error; } } return null; } /** * Gets the branches from the specified target label. * * @throws {InvalidTargetLabelError} Invalid label has been applied to pull request. * @throws {InvalidTargetBranchError} Invalid Github target branch has been selected. */ function getBranchesFromTargetLabel(label, githubTargetBranch) { return tslib.__awaiter(this, void 0, void 0, function () { var _a; return tslib.__generator(this, function (_b) { switch (_b.label) { case 0: if (!(typeof label.branches === 'function')) return [3 /*break*/, 2]; return [4 /*yield*/, label.branches(githubTargetBranch)]; case 1: _a = _b.sent(); return [3 /*break*/, 4]; case 2: return [4 /*yield*/, label.branches]; case 3: _a = _b.sent(); _b.label = 4; case 4: return [2 /*return*/, _a]; } }); }); } /** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ function getTargetBranchesForPr(prNumber) { return tslib.__awaiter(this, void 0, void 0, function* () { /** The ng-dev configuration. */ const config = getConfig(); /** Repo owner and name for the github repository. */ const { owner, name: repo } = config.github; /** The git client to get a Github API service instance. */ const git = new GitClient(undefined, config); /** The validated merge config. */ const { config: mergeConfig, errors } = yield loadAndValidateConfig(config, git.github); if (errors !== undefined) { throw Error(`Invalid configuration found: ${errors}`); } /** The current state of the pull request from Github. */ const prData = (yield git.github.pulls.get({ owner, repo, pull_number: prNumber })).data; /** The list of labels on the PR as strings. */ const labels = prData.labels.map(l => l.name); /** The branch targetted via the Github UI. */ const githubTargetBranch = prData.base.ref; /** The active label which is being used for targetting the PR. */ const targetLabel = getTargetLabelFromPullRequest(mergeConfig, labels); if (targetLabel === null) { error(red(`No target label was found on pr #${prNumber}`)); process.exitCode = 1; return; } /** The target branches based on the target label and branch targetted in the Github UI. */ return yield getBranchesFromTargetLabel(targetLabel, githubTargetBranch); }); } function printTargetBranchesForPr(prNumber) { return tslib.__awaiter(this, void 0, void 0, function* () { const targets = yield getTargetBranchesForPr(prNumber); if (targets === undefined) { return; } info.group(`PR #${prNumber} will merge into:`); targets.forEach(target => info(`- ${target}`)); info.groupEnd(); }); } /** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** Builds the command. */ function builder$5(yargs) { return yargs.positional('pr', { description: 'The pull request number', type: 'number', demandOption: true, }); } /** Handles the command. */ function handler$5({ pr }) { return tslib.__awaiter(this, void 0, void 0, function* () { yield printTargetBranchesForPr(pr); }); } /** yargs command module describing the command. */ const CheckTargetBranchesModule = { handler: handler$5, builder: builder$5, command: 'check-target-branches ', describe: 'Check a PR to determine what branches it is currently targeting', }; /** * @license * Copyright Google LLC All Rights Reserved. * * 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', '-q', 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$6(yargs) { return addGithubTokenOption(yargs).positional('prNumber', { type: 'number', demandOption: true }); } /** Handles the checkout pull request command. */ function handler$6({ prNumber, githubToken }) { return tslib.__awaiter(this, void 0, void 0, function* () { const prCheckoutOptions = { allowIfMaintainerCannotModify: true, branchName: `pr-${prNumber}` }; yield checkOutPullRequestLocally(prNumber, githubToken, prCheckoutOptions); }); } /** yargs command module for checking out a PR */ const CheckoutCommandModule = { handler: handler$6, builder: builder$6, 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 */ /** * 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 */ /** * 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', '-q', '-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, githubTargetBranch, baseSha, revisionRange, branchOrRevisionBeforeRebase, rebaseEnv, failedBranches, localBranch, sha; return tslib.__generator(this, function (_a) { switch (_a.label) { case 0: prNumber = pullRequest.prNumber, targetBranches = pullRequest.targetBranches, requiredBaseSha = pullRequest.requiredBaseSha, needsCommitMessageFixup = pullRequest.needsCommitMessageFixup, githubTargetBranch = pullRequest.githubTargetBranch; // 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); if (!(githubTargetBranch !== 'master')) return [3 /*break*/, 3]; localBranch = this.getLocalTargetBranchName(githubTargetBranch); sha = this.git.run(['rev-parse', localBranch]).stdout.trim(); // Create a comment saying the PR was closed by the SHA. return [4 /*yield*/, this.git.github.issues.createComment(tslib.__assign(tslib.__assign({}, this.git.remoteParams), { issue_number: pullRequest.prNumber, body: "Closed by commit " + sha }))]; case 1: // Create a comment saying the PR was closed by the SHA. _a.sent(); // Actually close the PR. return [4 /*yield*/, this.git.github.pulls.update(tslib.__assign(tslib.__assign({}, this.git.remoteParams), { pull_number: pullRequest.prNumber, state: 'closed' }))]; case 2: // Actually close the PR. _a.sent(); _a.label = 3; case 3: 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'] = '0'; 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', '-q', headRefUrl, headRefName]); git.run(['checkout', '-q', '--detach', 'FETCH_HEAD']); // Fetch the PRs target branch and rebase onto it. info(`Fetching ${fullBaseRef} to rebase #${prNumber} on`); git.run(['fetch', '-q', baseRefUrl, baseRefName]); const commonAncestorSha = git.run(['merge-base', 'HEAD', 'FETCH_HEAD']).stdout.trim(); const commits = parseCommitMessagesForRange(`${commonAncestorSha}..HEAD`); let squashFixups = commits.filter((commit) => commit.isFixup).length === 0 ? false : yield promptConfirm(`PR #${prNumber} contains fixup commits, would you like to squash them during rebase?`, true); info(`Attempting to rebase PR #${prNumber} on ${fullBaseRef}`); /** * Tuple of flags to be added to the rebase command and env object to run the git command. * * Additional flags to perform the autosquashing are added when the user confirm squashing of * fixup commits should occur. */ const [flags, env] = squashFixups ? [['--interactive', '--autosquash'], Object.assign(Object.assign({}, process.env), { GIT_SEQUENCE_EDITOR: 'true' })] : [[], undefined]; const rebaseResult = git.runGraceful(['rebase', ...flags, 'FETCH_HEAD'], { env: env }); // 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) .command(CheckTargetBranchesModule); } /** * @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$7(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$7(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$7, handler: handler$7, 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', '-q', 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(); // Verify the packages built are the correct version. yield this._verifyPackageVersions(newVersion, builtPackages); // 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)); }); } /** Verify the version of each generated package exact matches the specified version. */ _verifyPackageVersions(version, packages) { return tslib.__awaiter(this, void 0, void 0, function* () { for (const pkg of packages) { const { version: packageJsonVersion } = JSON.parse(yield fs.promises.readFile(path.join(pkg.outputPath, 'package.json'), 'utf8')); if (version.compare(packageJsonVersion) !== 0) { error(red('The built package version does not match the version being released.')); error(` Release Version: ${version.version}`); error(` Generated Version: ${packageJsonVersion}`); 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 */ /** * 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$8(argv) { return addGithubTokenOption(argv); } /** Yargs command handler for staging a release. */ function handler$8(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$8, handler: handler$8, 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$9(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$9(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$9, handler: handler$9, 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(' → '); } 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();