2020-10-26 22:42:23 -04:00
#!/usr/bin/env node
2020-10-01 19:06:56 -04:00
'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 ; }
2021-01-14 14:03:58 -05:00
// If the `.ts` extension has not been set up already, and a TypeScript based
2020-10-01 19:06:56 -04:00
// 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' , '' , '<LOCAL 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 ) {
2020-10-28 13:47:22 -04:00
var _this =
2020-10-01 19:06:56 -04:00
// 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 ) {
2020-10-28 13:47:22 -04:00
var _this =
2020-10-01 19:06:56 -04:00
// 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
2020-10-28 13:07:48 -04:00
// 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 ;
2020-10-01 19:06:56 -04:00
// 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 ( ' ' ) ) ) ;
2020-10-28 13:47:22 -04:00
var result = child _process . spawnSync ( 'git' , args , tslib . _ _assign ( tslib . _ _assign ( { cwd : this . _projectRoot , stdio : 'pipe' } , options ) , {
2020-10-01 19:06:56 -04:00
// 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 , '<TOKEN>' ) ;
} ;
/ * *
* 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 <TOKEN> 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 ( ) ; } ) ;
} ) ;
} ;
2020-10-28 13:47:22 -04:00
/** Whether verbose logging of Git actions should be used. */
GitClient . LOG _COMMANDS = true ;
2020-10-01 19:06:56 -04:00
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. */
2020-10-28 13:47:22 -04:00
branchName ,
2020-10-01 19:06:56 -04:00
/** 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 ` ;
}
2020-09-14 14:24:15 -04:00
/** 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 ( ) ;
}
}
2020-10-01 19:06:56 -04:00
/ * *
* @ 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
* /
2020-09-14 14:24:15 -04:00
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' ;
2020-10-01 19:06:56 -04:00
}
2020-09-14 14:24:15 -04:00
return 'not found' ;
} ) ;
}
2020-10-01 19:06:56 -04:00
}
/ * *
* @ 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
* /
2020-09-14 14:24:15 -04:00
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 ;
2020-10-01 19:06:56 -04:00
const angularRobotFilePath = path . join ( getRepoBaseDir ( ) , '.github/angular-robot.yml' ) ;
if ( ! fs . existsSync ( angularRobotFilePath ) ) {
2020-09-14 14:24:15 -04:00
debug ( 'No angular robot configuration file exists, skipping.' ) ;
return null ;
2020-10-01 19:06:56 -04:00
}
/** The configuration defined for the angular robot. */
const robotConfig = yaml . parse ( fs . readFileSync ( angularRobotFilePath ) . toString ( ) ) ;
/** The files to be included in the g3 sync. */
2020-09-14 14:24:15 -04:00
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 ) || [ ] ;
2020-10-01 19:06:56 -04:00
/** The files to be expected in the g3 sync. */
2020-09-14 14:24:15 -04:00
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 ;
2020-10-01 19:06:56 -04:00
}
2020-09-14 14:24:15 -04:00
return { include , exclude } ;
}
getLatestShas ( ) {
2020-10-01 19:06:56 -04:00
/** The latest sha for the g3 branch. */
2020-09-14 14:24:15 -04:00
const g3 = this . getShaForBranchLatest ( 'g3' ) ;
2020-10-01 19:06:56 -04:00
/** The latest sha for the master branch. */
2020-09-14 14:24:15 -04:00
const master = this . getShaForBranchLatest ( 'master' ) ;
if ( g3 === null || master === null ) {
debug ( 'Either the g3 or master was unable to be retrieved' ) ;
return null ;
2020-10-01 19:06:56 -04:00
}
2020-09-14 14:24:15 -04:00
return { g3 , master } ;
}
2020-10-01 19:06:56 -04:00
}
/ * *
* @ 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
* /
2020-09-14 14:24:15 -04:00
/** 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 ,
} ,
} ) ) ] ,
} ;
2020-10-01 19:06:56 -04:00
/ * *
2020-09-14 14:24:15 -04:00
* Cap the returned issues in the queries to an arbitrary 20. At that point , caretaker has a lot
2020-10-01 19:06:56 -04:00
* of work to do and showing more than that isn ' t really useful .
* /
const MAX _RETURNED _ISSUES = 20 ;
2020-09-14 14:24:15 -04:00
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 ) {
2020-10-01 19:06:56 -04:00
/** The query object for graphql. */
const graphQlQuery = { } ;
2020-09-14 14:24:15 -04:00
const { owner , name : repo } = this . git . remoteConfig ;
2020-10-01 19:06:56 -04:00
/** The Github search filter for the configured repository. */
2020-09-14 14:24:15 -04:00
const repoFilter = ` repo: ${ owner } / ${ repo } ` ;
2020-10-01 19:06:56 -04:00
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 , '\\"' ) } " ` ,
2020-09-14 14:24:15 -04:00
} , Object . assign ( { } , GithubQueryResultFragment ) ) ;
2020-10-01 19:06:56 -04:00
} ) ;
2020-09-14 14:24:15 -04:00
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 ( ) ;
2020-10-01 19:06:56 -04:00
}
}
2020-09-14 14:24:15 -04:00
info . groupEnd ( ) ;
info ( ) ;
2020-10-01 19:06:56 -04:00
} ) ;
2020-09-14 14:24:15 -04:00
}
2020-10-01 19:06:56 -04:00
}
/ * *
* @ 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
* /
2020-09-14 14:24:15 -04:00
/** 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 ) ) ) ;
} ) ;
2020-10-01 19:06:56 -04:00
}
2020-09-14 14:24:15 -04:00
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 )
} ;
} ) ;
2020-10-01 19:06:56 -04:00
}
}
/ * *
* @ 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
* /
2020-09-14 14:24:15 -04:00
/** List of modules checked for the caretaker check command. */
const moduleList = [
GithubQueriesModule ,
ServicesModule ,
CiModule ,
G3Module ,
] ;
2020-10-01 19:06:56 -04:00
/** 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 ) ;
2020-10-28 13:47:22 -04:00
// Prevent logging of the git commands being executed during the check.
GitClient . LOG _COMMANDS = false ;
2020-09-14 14:24:15 -04:00
/** List of instances of Caretaker Check modules */
const caretakerCheckModules = moduleList . map ( module => new module ( git , config ) ) ;
// Module's `data` is casted as Promise<unknown> 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 ( ) ;
}
2020-10-01 19:06:56 -04:00
} ) ;
}
/ * *
* @ 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 ) {
2020-11-18 11:48:59 -05:00
return yargs
. option ( 'file-env-variable' , {
2020-10-01 19:06:56 -04:00
type : 'string' ,
description : 'The key for the environment variable which holds the arguments for the\n' +
'prepare-commit-msg hook as described here:\n' +
2020-11-18 11:48:59 -05:00
'https://git-scm.com/docs/githooks#_prepare_commit_msg'
} )
. positional ( 'file' , { type : 'string' } )
. positional ( 'source' , { type : 'string' } ) ;
2020-10-01 19:06:56 -04:00
}
/** Handles the command. */
2020-11-18 11:48:59 -05:00
function handler$1 ( { fileEnvVariable , file , source } ) {
2020-10-01 19:06:56 -04:00
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
2020-11-18 11:48:59 -05:00
// 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' ) ;
2020-10-01 19:06:56 -04:00
} ) ;
}
/** yargs command module describing the command. */
const RestoreCommitMessageModule = {
handler : handler$1 ,
builder : builder$1 ,
2020-11-18 11:48:59 -05:00
command : 'restore-commit-message-draft [file] [source]' ,
2020-10-01 19:06:56 -04:00
// 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 ) ,
} ;
}
2020-11-17 13:08:40 -05:00
/** 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 ) ) ;
}
2020-10-01 19:06:56 -04:00
/ * *
* @ 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 ;
2020-11-17 14:20:41 -05:00
const commit = typeof commitMsg === 'string' ? parseCommitMessage ( commitMsg ) : commitMsg ;
2020-10-01 19:06:56 -04:00
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 ( '<type>(<scope>): <summary>' ) ;
print ( ) ;
print ( '<body>' ) ;
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' ,
} ;
2020-11-17 14:20:41 -05:00
/ * *
* @ 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 ) ;
2020-10-01 19:06:56 -04:00
info ( ` Examining ${ commits . length } commit(s) in the provided range: ${ range } ` ) ;
2020-11-17 14:20:41 -05:00
/ * *
* 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 ) => {
2020-10-01 19:06:56 -04:00
const options = {
disallowSquash : true ,
2020-11-17 14:20:41 -05:00
nonFixupCommitHeaders : isNonFixup ( commit ) ?
2020-10-01 19:06:56 -04:00
undefined :
commits . slice ( 0 , i ) . filter ( isNonFixup ) . map ( extractCommitHeader )
} ;
2020-11-17 14:20:41 -05:00
const { valid , errors : localErrors } = validateCommitMessage ( commit , options ) ;
2020-10-01 19:06:56 -04:00
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 ? '<no scope>' : '' ) ;
} ) ;
}
/** 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 = ` <type>(<scope>): <summary>
# < Describe the motivation behind this change - explain WHY you are making this change . Wrap all
# lines at 100 characters . > \ 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 <filePath> [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 <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 ( ) ) ;
}
2020-10-28 20:14:40 -04:00
/ * *
* @ 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 ;
2020-12-16 13:02:48 -05:00
/** List of discovered target labels for the PR. */
var matches = [ ] ;
2020-10-28 20:14:40 -04:00
var _loop _1 = function ( label ) {
var match = config . labels . find ( function ( _a ) {
var pattern = _a . pattern ;
return matchesPattern ( label , pattern ) ;
} ) ;
if ( match !== undefined ) {
2020-12-16 13:02:48 -05:00
matches . push ( match ) ;
2020-10-28 20:14:40 -04:00
}
} ;
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 ;
2020-12-16 13:02:48 -05:00
_loop _1 ( label ) ;
2020-10-28 20:14:40 -04:00
}
}
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 ; }
}
2020-12-16 13:02:48 -05:00
if ( matches . length === 1 ) {
return matches [ 0 ] ;
}
if ( matches . length === 0 ) {
throw new InvalidTargetLabelError ( 'Unable to determine target for the PR as it has no target label.' ) ;
}
throw new InvalidTargetLabelError ( 'Unable to determine target for the PR as it has multiple target labels.' ) ;
2020-10-28 20:14:40 -04:00
}
/ * *
* 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
* /
2020-11-30 17:53:05 -05:00
function getTargetBranchesForPr ( prNumber ) {
2020-10-28 20:14:40 -04:00
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. */
2020-12-16 13:02:48 -05:00
let targetLabel ;
try {
targetLabel = getTargetLabelFromPullRequest ( mergeConfig , labels ) ;
}
catch ( e ) {
if ( e instanceof InvalidTargetLabelError ) {
error ( red ( e . failureMessage ) ) ;
process . exitCode = 1 ;
return ;
}
throw e ;
2020-10-28 20:14:40 -04:00
}
/** The target branches based on the target label and branch targetted in the Github UI. */
2020-11-30 17:53:05 -05:00
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 ) {
2020-10-28 20:14:40 -04:00
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 ) {
2020-11-30 17:53:05 -05:00
return yargs . positional ( 'pr' , {
2020-10-28 20:14:40 -04:00
description : 'The pull request number' ,
type : 'number' ,
demandOption : true ,
} ) ;
}
/** Handles the command. */
2020-11-30 17:53:05 -05:00
function handler$5 ( { pr } ) {
2020-10-28 20:14:40 -04:00
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
2020-11-30 17:53:05 -05:00
yield printTargetBranchesForPr ( pr ) ;
2020-10-28 20:14:40 -04:00
} ) ;
}
/** yargs command module describing the command. */
const CheckTargetBranchesModule = {
handler : handler$5 ,
builder : builder$5 ,
command : 'check-target-branches <pr>' ,
describe : 'Check a PR to determine what branches it is currently targeting' ,
} ;
2020-10-01 19:06:56 -04:00
/ * *
* @ 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 } ` ) ;
2020-10-29 18:43:44 -04:00
git . run ( [ 'fetch' , '-q' , headRefUrl , headRefName ] ) ;
2020-10-01 19:06:56 -04:00
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. */
2020-10-28 20:14:40 -04:00
function builder$6 ( yargs ) {
2020-10-01 19:06:56 -04:00
return addGithubTokenOption ( yargs ) . positional ( 'prNumber' , { type : 'number' , demandOption : true } ) ;
}
/** Handles the checkout pull request command. */
2020-10-28 20:14:40 -04:00
function handler$6 ( { prNumber , githubToken } ) {
2020-10-01 19:06:56 -04:00
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 = {
2020-10-28 20:14:40 -04:00
handler : handler$6 ,
builder : builder$6 ,
2020-10-01 19:06:56 -04:00
command : 'checkout <pr-number>' ,
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 */
2020-10-28 13:47:22 -04:00
message ,
2020-10-01 19:06:56 -04:00
/** 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 . 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 ( ) ] ;
}
2020-12-16 13:02:48 -05:00
try {
targetLabel = getTargetLabelFromPullRequest ( config , labels ) ;
}
catch ( error ) {
if ( error instanceof InvalidTargetLabelError ) {
return [ 2 /*return*/ , new PullRequestFailure ( error . failureMessage ) ] ;
}
throw error ;
2020-10-01 19:06:56 -04:00
}
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.
2020-10-29 18:43:44 -04:00
this . git . run ( tslib . _ _spread ( [ 'fetch' , '-q' , '-f' , this . git . repoGitUrl ] , fetchRefspecs , extraRefspecs ) ) ;
2020-10-01 19:06:56 -04:00
} ;
/** 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 ( ) {
2020-12-04 18:16:14 -05:00
var prNumber , targetBranches , requiredBaseSha , needsCommitMessageFixup , githubTargetBranch , baseSha , revisionRange , branchOrRevisionBeforeRebase , rebaseEnv , failedBranches , localBranch , sha ;
2020-10-01 19:06:56 -04:00
return tslib . _ _generator ( this , function ( _a ) {
2020-12-04 18:16:14 -05:00
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 ] ;
2020-10-01 19:06:56 -04:00
}
} ) ;
} ) ;
} ;
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.
2020-12-08 12:12:11 -05:00
process . env [ 'HUSKY' ] = '0' ;
2020-10-01 19:06:56 -04:00
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 } ` ) ;
2020-10-29 18:43:44 -04:00
git . run ( [ 'fetch' , '-q' , headRefUrl , headRefName ] ) ;
2020-11-17 13:08:40 -05:00
git . run ( [ 'checkout' , '-q' , '--detach' , 'FETCH_HEAD' ] ) ;
2020-10-01 19:06:56 -04:00
// Fetch the PRs target branch and rebase onto it.
info ( ` Fetching ${ fullBaseRef } to rebase # ${ prNumber } on ` ) ;
2020-10-29 18:43:44 -04:00
git . run ( [ 'fetch' , '-q' , baseRefUrl , baseRefName ] ) ;
2020-11-17 13:08:40 -05:00
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 ) ;
2020-10-01 19:06:56 -04:00
info ( ` Attempting to rebase PR # ${ prNumber } on ${ fullBaseRef } ` ) ;
2020-11-17 13:08:40 -05:00
/ * *
* 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 } ) ;
2020-10-01 19:06:56 -04:00
// 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 <pr-number>' , 'Merge pull requests' , buildMergeCommand , handleMergeCommand )
. command ( 'discover-new-conflicts <pr-number>' , 'Check if a pending PR causes new conflicts for other pending PRs' , buildDiscoverNewConflictsCommand , handleDiscoverNewConflictsCommand )
. command ( 'rebase <pr-number>' , 'Rebase a pending PR and push the rebased commits back to Github' , buildRebaseCommand , handleRebaseCommand )
2020-10-28 20:14:40 -04:00
. command ( CheckoutCommandModule )
. command ( CheckTargetBranchesModule ) ;
2020-10-01 19:06:56 -04:00
}
/ * *
* @ 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. */
2020-10-28 20:14:40 -04:00
function builder$7 ( argv ) {
2020-10-01 19:06:56 -04:00
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. */
2020-10-28 20:14:40 -04:00
function handler$7 ( args ) {
2020-10-01 19:06:56 -04:00
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 = {
2020-10-28 20:14:40 -04:00
builder : builder$7 ,
handler : handler$7 ,
2020-10-01 19:06:56 -04:00
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
* /
2020-12-17 15:17:38 -05:00
/** Thirty seconds in milliseconds. */
const THIRTY _SECONDS _IN _MS = 30000 ;
2020-10-01 19:06:56 -04:00
/** 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' ;
}
2020-12-17 15:17:38 -05:00
// Check if the PR was closed more than 30 seconds ago, this extra time gives Github time to
// update the closed pull request to be associated with the closing commit.
// Note: a Date constructed with `null` creates an object at 0 time, which will never be greater
// than the current date time.
if ( data . closed _at !== null &&
( new Date ( data . closed _at ) . getTime ( ) < Date . now ( ) - THIRTY _SECONDS _IN _MS ) ) {
2020-10-01 19:06:56 -04:00
return ( yield isPullRequestClosedWithAssociatedCommit ( api , id ) ) ? 'merged' : 'closed' ;
}
2020-12-17 15:17:38 -05:00
return 'open' ;
2020-10-01 19:06:56 -04:00
} ) ;
}
/ * *
* 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 ( ` (<a name=" ${ escapedVersion } "></a>.*?)(?:<a name="| $ ) ` , 's' ) ;
}
/** Gets the path for the changelog file in a given project. */
function getLocalChangelogFilePath ( projectDir ) {
return path . join ( projectDir , changelogPath ) ;
}
/ * *
* @ 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
* /
/ * *
* Abstract base class for a release action . A release action is selectable by the caretaker
* if active , and can perform changes for releasing , such as staging a release , bumping the
* version , cherry - picking the changelog , branching off from master . etc .
* /
class ReleaseAction {
constructor ( active , git , config , projectDir ) {
this . active = active ;
this . git = git ;
this . config = config ;
this . projectDir = projectDir ;
/** Cached found fork of the configured project. */
this . _cachedForkRepo = null ;
}
/** Whether the release action is currently active. */
static isActive ( _trains ) {
throw Error ( 'Not implemented.' ) ;
}
/** Updates the version in the project top-level `package.json` file. */
updateProjectVersion ( newVersion ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
const pkgJsonPath = path . join ( this . projectDir , packageJsonPath ) ;
const pkgJson = JSON . parse ( yield fs . promises . readFile ( pkgJsonPath , 'utf8' ) ) ;
pkgJson . version = newVersion . format ( ) ;
// Write the `package.json` file. Note that we add a trailing new line
// to avoid unnecessary diff. IDEs usually add a trailing new line.
yield fs . promises . writeFile ( pkgJsonPath , ` ${ JSON . stringify ( pkgJson , null , 2 ) } \n ` ) ;
info ( green ( ` ✓ Updated project version to ${ pkgJson . version } ` ) ) ;
} ) ;
}
/** Gets the most recent commit of a specified branch. */
_getCommitOfBranch ( branchName ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
const { data : { commit } } = yield this . git . github . repos . getBranch ( Object . assign ( Object . assign ( { } , this . git . remoteParams ) , { branch : branchName } ) ) ;
return commit . sha ;
} ) ;
}
/** Verifies that the latest commit for the given branch is passing all statuses. */
verifyPassingGithubStatus ( branchName ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
const commitSha = yield this . _getCommitOfBranch ( branchName ) ;
const { data : { state } } = yield this . git . github . repos . getCombinedStatusForRef ( Object . assign ( Object . assign ( { } , this . git . remoteParams ) , { ref : commitSha } ) ) ;
const branchCommitsUrl = getListCommitsInBranchUrl ( this . git , branchName ) ;
if ( state === 'failure' ) {
error ( red ( ` ✘ Cannot stage release. Commit " ${ commitSha } " does not pass all github ` +
'status checks. Please make sure this commit passes all checks before re-running.' ) ) ;
error ( ` Please have a look at: ${ branchCommitsUrl } ` ) ;
if ( yield promptConfirm ( 'Do you want to ignore the Github status and proceed?' ) ) {
info ( yellow ( ' ⚠ Upstream commit is failing CI checks, but status has been forcibly ignored.' ) ) ;
return ;
}
throw new UserAbortedReleaseActionError ( ) ;
}
else if ( state === 'pending' ) {
error ( red ( ` ✘ Commit " ${ commitSha } " still has pending github statuses that ` +
'need to succeed before staging a release.' ) ) ;
error ( red ( ` Please have a look at: ${ branchCommitsUrl } ` ) ) ;
if ( yield promptConfirm ( 'Do you want to ignore the Github status and proceed?' ) ) {
info ( yellow ( ' ⚠ Upstream commit is pending CI, but status has been forcibly ignored.' ) ) ;
return ;
}
throw new UserAbortedReleaseActionError ( ) ;
}
info ( green ( ' ✓ Upstream commit is passing all github status checks.' ) ) ;
} ) ;
}
/** Generates the changelog for the specified for the current `HEAD`. */
_generateReleaseNotesForHead ( version ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
const changelogPath = getLocalChangelogFilePath ( this . projectDir ) ;
yield this . config . generateReleaseNotesForHead ( changelogPath ) ;
info ( green ( ` ✓ Updated the changelog to capture changes for " ${ version } ". ` ) ) ;
} ) ;
}
/** Extract the release notes for the given version from the changelog file. */
_extractReleaseNotesForVersion ( changelogContent , version ) {
const pattern = this . config . extractReleaseNotesPattern !== undefined ?
this . config . extractReleaseNotesPattern ( version ) :
getDefaultExtractReleaseNotesPattern ( version ) ;
const matchedNotes = pattern . exec ( changelogContent ) ;
return matchedNotes === null ? null : matchedNotes [ 1 ] ;
}
/ * *
* Prompts the user for potential release notes edits that need to be made . Once
* confirmed , a new commit for the release point is created .
* /
waitForEditsAndCreateReleaseCommit ( newVersion ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
info ( yellow ( ' ⚠ Please review the changelog and ensure that the log contains only changes ' +
'that apply to the public API surface. Manual changes can be made. When done, please ' +
'proceed with the prompt below.' ) ) ;
if ( ! ( yield promptConfirm ( 'Do you want to proceed and commit the changes?' ) ) ) {
throw new UserAbortedReleaseActionError ( ) ;
}
// Commit message for the release point.
const commitMessage = getCommitMessageForRelease ( newVersion ) ;
// Create a release staging commit including changelog and version bump.
yield this . createCommit ( commitMessage , [ packageJsonPath , changelogPath ] ) ;
info ( green ( ` ✓ Created release commit for: " ${ newVersion } ". ` ) ) ;
} ) ;
}
/ * *
* Gets an owned fork for the configured project of the authenticated user . Aborts the
* process with an error if no fork could be found . Also caches the determined fork
* repository as the authenticated user cannot change during action execution .
* /
_getForkOfAuthenticatedUser ( ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
if ( this . _cachedForkRepo !== null ) {
return this . _cachedForkRepo ;
}
const { owner , name } = this . git . remoteConfig ;
const result = yield this . git . github . graphql . query ( findOwnedForksOfRepoQuery , { owner , name } ) ;
const forks = result . repository . forks . nodes ;
if ( forks . length === 0 ) {
error ( red ( ' ✘ Unable to find fork for currently authenticated user.' ) ) ;
error ( red ( ` Please ensure you created a fork of: ${ owner } / ${ name } . ` ) ) ;
throw new FatalReleaseActionError ( ) ;
}
const fork = forks [ 0 ] ;
return this . _cachedForkRepo = { owner : fork . owner . login , name : fork . name } ;
} ) ;
}
/** Checks whether a given branch name is reserved in the specified repository. */
_isBranchNameReservedInRepo ( repo , name ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
try {
yield this . git . github . repos . getBranch ( { owner : repo . owner , repo : repo . name , branch : name } ) ;
return true ;
}
catch ( e ) {
// If the error has a `status` property set to `404`, then we know that the branch
// does not exist. Otherwise, it might be an API error that we want to report/re-throw.
if ( e . status === 404 ) {
return false ;
}
throw e ;
}
} ) ;
}
/** Finds a non-reserved branch name in the repository with respect to a base name. */
_findAvailableBranchName ( repo , baseName ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
let currentName = baseName ;
let suffixNum = 0 ;
while ( yield this . _isBranchNameReservedInRepo ( repo , currentName ) ) {
suffixNum ++ ;
currentName = ` ${ baseName } _ ${ suffixNum } ` ;
}
return currentName ;
} ) ;
}
/ * *
* Creates a local branch from the current Git ` HEAD ` . Will override
* existing branches in case of a collision .
* /
createLocalBranchFromHead ( branchName ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
this . git . run ( [ 'checkout' , '-B' , branchName ] ) ;
} ) ;
}
/** Pushes the current Git `HEAD` to the given remote branch in the configured project. */
pushHeadToRemoteBranch ( branchName ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
// Push the local `HEAD` to the remote branch in the configured project.
this . git . run ( [ 'push' , this . git . repoGitUrl , ` HEAD:refs/heads/ ${ branchName } ` ] ) ;
} ) ;
}
/ * *
* Pushes the current Git ` HEAD ` to a fork for the configured project that is owned by
* the authenticated user . If the specified branch name exists in the fork already , a
* unique one will be generated based on the proposed name to avoid collisions .
* @ param proposedBranchName Proposed branch name for the fork .
* @ param trackLocalBranch Whether the fork branch should be tracked locally . i . e . whether
* a local branch with remote tracking should be set up .
* @ returns The fork and branch name containing the pushed changes .
* /
_pushHeadToFork ( proposedBranchName , trackLocalBranch ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
const fork = yield this . _getForkOfAuthenticatedUser ( ) ;
// Compute a repository URL for pushing to the fork. Note that we want to respect
// the SSH option from the dev-infra github configuration.
const repoGitUrl = getRepositoryGitUrl ( Object . assign ( Object . assign ( { } , fork ) , { useSsh : this . git . remoteConfig . useSsh } ) , this . git . githubToken ) ;
const branchName = yield this . _findAvailableBranchName ( fork , proposedBranchName ) ;
const pushArgs = [ ] ;
// If a local branch should track the remote fork branch, create a branch matching
// the remote branch. Later with the `git push`, the remote is set for the branch.
if ( trackLocalBranch ) {
yield this . createLocalBranchFromHead ( branchName ) ;
pushArgs . push ( '--set-upstream' ) ;
}
// Push the local `HEAD` to the remote branch in the fork.
this . git . run ( [ 'push' , repoGitUrl , ` HEAD:refs/heads/ ${ branchName } ` , ... pushArgs ] ) ;
return { fork , branchName } ;
} ) ;
}
/ * *
* Pushes changes to a fork for the configured project that is owned by the currently
* authenticated user . A pull request is then created for the pushed changes on the
* configured project that targets the specified target branch .
* @ returns An object describing the created pull request .
* /
pushChangesToForkAndCreatePullRequest ( targetBranch , proposedForkBranchName , title , body ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
const repoSlug = ` ${ this . git . remoteParams . owner } / ${ this . git . remoteParams . repo } ` ;
const { fork , branchName } = yield this . _pushHeadToFork ( proposedForkBranchName , true ) ;
const { data } = yield this . git . github . pulls . create ( Object . assign ( Object . assign ( { } , this . git . remoteParams ) , { head : ` ${ fork . owner } : ${ branchName } ` , base : targetBranch , body ,
title } ) ) ;
info ( green ( ` ✓ Created pull request # ${ data . number } in ${ repoSlug } . ` ) ) ;
return {
id : data . number ,
url : data . html _url ,
fork ,
forkBranch : branchName ,
} ;
} ) ;
}
/ * *
* Waits for the given pull request to be merged . Default interval for checking the Github
* API is 10 seconds ( to not exceed any rate limits ) . If the pull request is closed without
* merge , the script will abort gracefully ( considering a manual user abort ) .
* /
waitForPullRequestToBeMerged ( id , interval = waitForPullRequestInterval ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
return new Promise ( ( resolve , reject ) => {
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 * ( ) {
2020-10-29 18:43:44 -04:00
this . git . run ( [ 'fetch' , '-q' , this . git . repoGitUrl , branchName ] ) ;
2020-10-01 19:06:56 -04:00
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 ( ) ;
2020-11-20 14:05:40 -05:00
// Verify the packages built are the correct version.
yield this . _verifyPackageVersions ( newVersion , builtPackages ) ;
2020-10-01 19:06:56 -04:00
// 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 ) ) ;
} ) ;
}
2020-11-20 14:05:40 -05:00
/** 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 ( ) ;
}
}
} ) ;
}
2020-10-01 19:06:56 -04:00
}
/ * *
* @ 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 \n Also 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. */
2020-10-28 20:14:40 -04:00
function builder$8 ( argv ) {
2020-10-01 19:06:56 -04:00
return addGithubTokenOption ( argv ) ;
}
/** Yargs command handler for staging a release. */
2020-10-28 20:14:40 -04:00
function handler$8 ( args ) {
2020-10-01 19:06:56 -04:00
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 = {
2020-10-28 20:14:40 -04:00
builder : builder$8 ,
handler : handler$8 ,
2020-10-01 19:06:56 -04:00
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
* /
2020-10-28 20:14:40 -04:00
function builder$9 ( args ) {
2020-10-01 19:06:56 -04:00
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. */
2020-10-28 20:14:40 -04:00
function handler$9 ( args ) {
2020-10-01 19:06:56 -04:00
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 = {
2020-10-28 20:14:40 -04:00
builder : builder$9 ,
handler : handler$9 ,
2020-10-01 19:06:56 -04:00
command : 'set-dist-tag <tag-name> <target-version>' ,
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).
* /
2020-12-11 15:34:44 -05:00
function buildEnvStamp ( mode ) {
2020-10-01 19:06:56 -04:00
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 ( ) } ` ) ;
2020-12-11 15:34:44 -05:00
console . info ( ` BUILD_SCM_VERSION ${ getSCMVersion ( mode ) } ` ) ;
2020-10-01 19:06:56 -04:00
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 ` ) ;
}
2020-12-11 15:34:44 -05:00
/ * *
* Get the version for generated packages .
*
* In snapshot mode , the version is based on the most recent semver tag .
* In release mode , the version is based on the base package . json version .
* /
function getSCMVersion ( mode ) {
if ( mode === 'release' ) {
const packageJsonPath = path . join ( getRepoBaseDir ( ) , 'package.json' ) ;
const { version } = require ( packageJsonPath ) ;
return version ;
}
if ( mode === 'snapshot' ) {
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' : '' ) } ` ;
}
return '0.0.0' ;
2020-10-01 19:06:56 -04:00
}
/** 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 } > ` ;
}
2020-12-11 15:34:44 -05:00
/ * *
* @ 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$a ( args ) {
return args . option ( 'mode' , {
demandOption : true ,
description : 'Whether the env-stamp should be built for a snapshot or release' ,
choices : [ 'snapshot' , 'release' ]
} ) ;
}
function handler$a ( { mode } ) {
return tslib . _ _awaiter ( this , void 0 , void 0 , function * ( ) {
buildEnvStamp ( mode ) ;
} ) ;
}
/** CLI command module for building the environment stamp. */
const BuildEnvStampCommand = {
builder : builder$a ,
handler : handler$a ,
command : 'build-env-stamp' ,
describe : 'Build the environment stamping information' ,
} ;
2020-10-01 19:06:56 -04:00
/** Build the parser for the release commands. */
function buildReleaseParser ( localYargs ) {
return localYargs . help ( )
. strict ( )
. demandCommand ( )
. command ( ReleasePublishCommandModule )
. command ( ReleaseBuildCommandModule )
. command ( ReleaseSetDistTagCommand )
2020-12-11 15:34:44 -05:00
. command ( BuildEnvStampCommand ) ;
2020-10-01 19:06:56 -04:00
}
/ * *
* @ 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 <command>' , '' , buildCommitMessageParser )
. command ( 'format <command>' , '' , buildFormatParser )
. command ( 'pr <command>' , '' , buildPrParser )
. command ( 'pullapprove <command>' , '' , buildPullapproveParser )
. command ( 'release <command>' , '' , buildReleaseParser )
. command ( 'ts-circular-deps <command>' , '' , tsCircularDependenciesBuilder )
. command ( 'caretaker <command>' , '' , buildCaretakerParser )
. command ( 'ngbot <command>' , false , buildNgbotParser )
. wrap ( 120 )
. strict ( )
. parse ( ) ;