From b5a68f81b027ce4a8d010b543bfec81ea3925013 Mon Sep 17 00:00:00 2001 From: mattsqd Date: Wed, 5 Apr 2023 09:28:02 -0400 Subject: [PATCH] Initial code release --- .editorconfig | 15 + .gitignore | 4 + README.md | 62 ++ composer.json | 34 + robo.example.yml | 99 +++ robo.yml | 10 + src/Robo/Plugin/Commands/ValidateCommands.php | 687 ++++++++++++++++++ 7 files changed, 911 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 robo.example.yml create mode 100644 robo.yml create mode 100644 src/Robo/Plugin/Commands/ValidateCommands.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c7dade --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_size = 4 +indent_style = space + +[**.php] +max_line_length = 120 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c4fed2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +vendor/ +# Ignore ide files +.vscode/ +/.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..83eaf37 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Robo Validate Commands + +**A group of [Robo](https://robo.li) commands that run various validation tasks on local environments or pipelines** + +* Coding standards (``validate:coding-standards``) + * Uses PHPCS to validate code. + * Zero config for Drupal projects. +* Composer lock (``validate:composer-lock``) + * Ensures you don't get this message during ``composer install``: +> Warning: The lock file is not up to date with the latest changes in composer.json. You may be getting outdated dependencies. Run update to update them. +* Commit messages (``validate:commit-messages``) + * Validate commit messages against a regular expression. +* Branch name (``validate:branch-name``) + * Validate a branch name against a regular expression. + * Note: This command has an optional single parameter which is the branch name if the current branch name cannot be determined automatically. +* Run all the above (``validate:all)`` + +## Installing + +``composer require mattsqd/robovalidate`` + +## Usage + +Use `vendor/bin/robo` to execute Robo tasks. + +## Configuration + +There are two ways to configure the commands. +1. Pass options to the command as they run. +2. Create (or update) a robo.yml in your root directory that holds the options. + +Number two is the easiest as some options are arrays, and it gets very ugly trying to pass arrays at run time. + +All the options live under the `command.validate.options` namespace in robo.yml. + +Some commands share the same option, such as project-id. So changing it will affect all commands unless you move that key under the specific commands section. You can see an example of this with 'pattern' which is used in two commands. + +### Quick Start + +The quick start assumes: +* This is a Drupal project using Drupal and DrupalPractice coding standards. +* You want commit messages like: 'ABC-1234: A short message' and you're merging into origin/develop. +* You want branches named like: main, develop, hotfix/3.3.1, release/3.3.0, and feature/ABC-123-a-short-message. +* Your composer.lock lives in the root directory and composer lives at vendor/bin/composer. + +This can be configured by creating ``robo.yml`` in your project root with the following content: +``` yml +command: + validate: + options: + project-id: ABC +``` + +And run `vendor/bin/robo validate:all` + +If any of the above does not apply you can either: +* Call individual commands instead of ``validate:all`` or +* Configure your robo.yml as in robo.example.yml. + +Please see robo.example.yml for an example of the defaults configured explicitly. + +For each option, you only need to override the ones you want to change, you don't need to copy the entire file, although you can. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0840ebb --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "mattsqd/robovalidate", + "description": "A group of Robo commands that run various validation tasks on local environments or pipelines", + "authors": [ + { + "name": "mattsqd", + "email": "mattsqd@users.noreply.github.com" + } + ], + "autoload": { + "psr-4": { + "RoboValidate\\": "src" + } + }, + "type": "robo-tasks", + "license": "GPL-2.0-or-later", + "require": { + "consolidation/robo": "^3.0.9 || ^4.0.1", + "php": ">=8.0.17" + }, + "suggest": { + "squizlabs/php_codesniffer": "Recommended if wanting to validate coding standards." + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.6" + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.0.17" + } + } +} diff --git a/robo.example.yml b/robo.example.yml new file mode 100644 index 0000000..941f39c --- /dev/null +++ b/robo.example.yml @@ -0,0 +1,99 @@ +# These are the 'default' options if no options or robo.yml are in place. +command: + validate: + # Used in 'validate:coding-standards' and 'validate:branch-name' + options: + # This is used as a token replacement. It is most useful for 'pattern' so + # you can ensure that commit and branch names have a issue number in them + # so that Jira, Github, or Gitlab know which commits and branches belong + # to which issue. + # For example, if you bitbucket issue was XZY-1234, you canmake project-id + # XYZ. + project-id: '' + # Used in 'validate:coding-standards' + # Requires https://github.com/squizlabs/PHP_CodeSniffer be installed. + coding-standards: + options: + # This is initially configured for Drupal. However, this can be any PHPCS standard. + # Individual PHPCS commands cannot use more than one standar, so this will cause + # the same command to be run for each standard for each path. + standards: + - Drupal + - DrupalPractice + # This is required for non-Drupal projects. It will be in the form of: + # path/to/files/a: + # extensions: 'php,module,inc' + # ignore: '*node_modules/*,*bower_components/*,*vendor/*,*.min.js,*.min.css' + # path/to/files/b: {} + # path/to/files/c: + # standard: + # - psr2 + # The key is used as the path, and the value are the options passed to PHPCS. + # If no options are given, as in 'b', then 'similar-options' will be used. + # If any options are given, then similar options will not be used, so you must + # add any options in 'similar-options' that should be there in addition to the + # overrides. + # The 'standard' is different. If it is not given, it will use the 'standards' option. + # If it is given, it will override 'standards' option. + # If 'standard' is only given, as in 'c' 'similar-options' WILL still be applied. + paths: {} + # These are the options passed to PHPCS if 'paths' does not set any options. + # Please see https://github.com/squizlabs/PHP_CodeSniffer/wiki/Configuration-Options. + similar-options: + colors: '' + extensions: 'php,module,inc,install,test,profile,theme,css,info' + ignore: '*node_modules/*,*bower_components/*,*vendor/*,*.min.js,*.min.css' + # Used in 'validate:composer-lock' + composer-lock: + # Can be set to a global location if needed. + composer-path: 'vendor/bin/composer' + # Only useful if composer.json lives in something besides the root directory. + working-directory: './' + # Used in 'validate:commit-messages' + commit-messages: + # This is the branch that the current branch is going to be merged into. + target-branch: develop + # The name of the git remote to pull the latest commits from. + git-remote: origin + # The regular expression that much match for all commits not in 'target-branch'. + # Note how {$project_id} is not part of the pattern but replaced with whatever is + # in 'project-id' before the regular expression is used. + pattern: '/^{$project_id}-(\d{4}): /' + # These next two describe how the commits should be formed. + short-help: 'Commit messages must start with: ''{$project_id}-x:y''' + long-help: + - 'Where x is the ticket number.' + - 'And y is a space.' + - '' + - 'How do I update my commit messages?' + - 'See https://www.atlassian.com/git/tutorials/rewriting-history' + - '' + - 'After re-rewriting the history, you should --force-with-lease the push.' + - 'https://stackoverflow.com/questions/52823692/git-push-force-with-lease-vs-force#:~:text=%2D%2Dforce%2Dwith%2Dlease%20is%20a%20safer%20option%20that%20will,elses%20work%20by%20force%20pushing.' + # Used in 'validate:branch-name' + branch-name: + options: + # The regular expression that must match the branch name. + pattern: '/^feature\/{$project_id}-([\d]{1,})-(?!.*--)([a-z\d]{1})([a-z\d-]{3,})([a-z\d]{1})$/' + # Show help messages if the branch name does not match. + custom-help: + - 'feature/{$project_id}-* where * can be:' + - ' - Always lower case.' + - ' - Starts and end with a letter or integer.' + - ' - Contains letters, integers, or dashes (non-consecutive).' + - ' - At least 5 characters long.' + # These refer to all the possible branch names. There are 4 different types and they will be + # described below. If you'd like to override any of these, you must put all back in that you'd like to + # use, they will not be merged together. + valid-branch-names: + options: + # Matches a branch named 'develop'. + - 'explicit|develop' + # Matches a branch named 'main'. + - 'explicit|main' + # Matches a custom regular expression found in $pattern. + - 'custom|' + # Matches a branch like: hotfix/2.1.3. + - 'semantic|hotfix' + # Matches a branch like (the last number MUST be a 0): release/2.1.0. + - 'semantic_end_0|release' diff --git a/robo.yml b/robo.yml new file mode 100644 index 0000000..8ee5355 --- /dev/null +++ b/robo.yml @@ -0,0 +1,10 @@ +# This file is used when contributing to check for coding standards. +command: + validate: + coding-standards: + options: + paths: + # Robo projects use psr2 standards. + src: + extensions: 'php' + standard: psr2 diff --git a/src/Robo/Plugin/Commands/ValidateCommands.php b/src/Robo/Plugin/Commands/ValidateCommands.php new file mode 100644 index 0000000..932330b --- /dev/null +++ b/src/Robo/Plugin/Commands/ValidateCommands.php @@ -0,0 +1,687 @@ +say(''); + $this->say(str_repeat('-', strlen($message))); + $this->say($message); + $this->say(str_repeat('-', strlen($message))); + $this->say(''); + } + + protected function printError($message) + { + $this->yell($message, 40, 'red'); + } + + /** + * Is the current working directory a git repo? + * + * @return bool + */ + protected function isGitRepo(): bool + { + return exec('git rev-parse --is-inside-work-tree 2>/dev/null') === 'true'; + } + + /** + * Retrieve the name of the current Git branch. + * + * @return string|null + * Null if not on a branch. + */ + protected function getGitBranch(): ?string + { + if (!$this->isGitRepo()) { + $this->printError( + 'The current directory is not a git repo, cannot retrieve branch name' + ); + + return null; + } + $branch_name = exec('git branch --show-current'); + + return $branch_name !== '' ? $branch_name : null; + } + + /** + * Run a robo command and return the result. + * + * @param string $command + * A command string to run in robo, except don't start with the robo path. + * + * @return \Robo\Result + */ + protected function runRoboCommand(string $command): ResultData + { + global $argv; + + return $this->_exec($argv[0].' '.$command); + } + + /** + * Ensure that the $keys in $opts have a value. + * + * @param array|string $keys + * An array of keys in $opts or just a single string key. + * @param array $opts + * The options and their values but numerically indexed. + * + * @return array|string + * + * @throws \Exception + */ + protected function getOptions( + array|string $keys, + array $opts, + bool $required = true, + ): string|array { + $was_string = is_string($keys); + $keys = (array)$keys; + $return = []; + $errors = []; + foreach ($keys as $key) { + if ($required && !isset($opts[$key])) { + $errors[] = sprintf('The option --%s is required.', $key); + } elseif ($required && + ( + is_scalar($opts[$key]) && 0 === strlen($opts[$key]) || + !is_scalar($opts[$key]) && empty(array_filter($opts[$key])) + ) + ) { + $errors[] = sprintf('The option --%s must have a value.', $key); + } else { + $return[] = $opts[$key]; + } + } + if (!empty($errors)) { + foreach ($errors as $error) { + $this->printError($error); + } + throw new \Exception('Invalid option(s) given.'); + } + + return $was_string ? $return[0] : $return; + } + + /** + * Run all validations. + * + * @command validate:all + */ + public function validateAll(): ResultData + { + $table = new Table($this->output()); + $table + ->setHeaders( + [ + 'Test Name', + 'Valid?', + 'Command (use this to diagnose individual tests without running all)', + ] + ); + $output_data[] = [ + 'Coding Standards', + $this->runRoboCommand('validate:coding-standards')->wasSuccessful( + ) ? 'Yes' : 'No', + 'validate:coding-standards', + ]; + $output_data[] = [ + 'Composer Lock File', + $this->runRoboCommand('validate:composer-lock')->wasSuccessful( + ) ? 'Yes' : 'No', + 'validate:composer-lock', + ]; + $output_data[] = [ + 'Commit Messages', + $this->runRoboCommand('validate:commit-messages')->wasSuccessful( + ) ? 'Yes' : 'No', + "validate:commit-messages", + ]; + $output_data[] = [ + 'Branch Name', + $this->runRoboCommand('validate:branch-name')->wasSuccessful( + ) ? 'Yes' : 'No', + "validate:branch-name", + ]; + $table->setRows($output_data); + $success = true; + foreach ($output_data as $datum) { + if ($success && trim($datum[1]) === 'No') { + $success = false; + } + } + $table->render(); + if ($success) { + $this->printError('All tests are valid'); + } else { + $this->printError('At least one test has failed'); + } + + return new ResultData(); + } + + /** + * Ensure that coding standards pass. + * + * This is initially configured for Drupal which requires the + * 'drupal/core-dev' composer package. However, it can be used with other + * standards. See https://github.com/PHPCSStandards/composer-installer for + * more information. + * + * @command validate:coding-standards + * + * @option standards If multiple standards are set, it will run both + * standards on all $paths. + * @option paths By default, this will look in $composer_json_path for where + * your custom code is placed. If it does not find it there, it will + * default to {$web_root}/modules/custom, {$web_root}/modules/custom, + * {$web_root}/themes/custom. If any directory does not exist, it will not + * run. Note all that paths is in the form: ['path/on/disk' => [options]]. + * If options is empty, it will use $similar_options. + * @option similar-options These are the options passed to phpcs. + * @option composer-json-path The path to composer.json on disk. + * + * @return \Robo\ResultData + */ + public function validateCodingStandards( + array $opts = [ + 'standards' => ['Drupal', 'DrupalPractice'], + 'paths' => [], + 'similar-options' => [ + 'standard' => '', + 'colors' => '', + 'extensions' => 'php,module,inc,install,test,profile,theme,css,info', + 'ignore' => '*node_modules/*,*bower_components/*,*vendor/*,*.min.js,*.min.css', + ], + 'composer-json-path' => 'composer.json', + ] + ): ResultData { + [ + $paths, + $similar_options, + ] = $this->getOptions([ + 'paths', + 'similar-options', + ], $opts, false); + [ + $standards, + $composer_json_path, + ] = $this->getOptions([ + 'standards', + 'composer-json-path', + ], $opts); + $this->sayWithWrapper('Checking for coding standards issues.'); + // Load composer.json and determine where web root and paths are. + if (empty($paths)) { + if (!empty($composer_json_path) && + file_exists($composer_json_path) + ) { + $composer = json_decode( + file_get_contents($composer_json_path), + true + ); + $web_root = $composer['extra']['drupal-scaffold']['locations']['web-root']; + if (!empty($composer['extra']['installer-paths'])) { + foreach ($composer['extra']['installer-paths'] as $key => $installer_paths) { + if (!empty($installer_paths)) { + switch ($installer_paths[0]) { + case 'type:drupal-custom-module': + $custom_modules_path = str_replace( + '/{$name}', + '', + $key + ); + continue 2; + + case 'type:drupal-custom-profile': + $custom_profiles_path = str_replace( + '/{$name}', + '', + $key + ); + continue 2; + + case 'type:drupal-custom-theme': + $custom_theme_path = str_replace( + '/{$name}', + '', + $key + ); + continue 2; + } + } + } + } + } + $web_root = $web_root ?? 'web/'; + // Ensure web root ends in "/". + $web_root = !str_ends_with( + $web_root, + '/' + ) ? $web_root.'/' : $web_root; + + // Set defaults if not found in composer.json. + $custom_modules_path = $custom_modules_path ?? $web_root.'modules/custom'; + $custom_profiles_path = $custom_profiles_path ?? $web_root.'profiles/custom'; + $custom_theme_path = $custom_theme_path ?? $web_root.'themes/custom'; + + $paths = [ + $custom_modules_path => $similar_options, + $custom_profiles_path => $similar_options, + $custom_theme_path => $similar_options, + ]; + } else { + // Go through each explicitly configured path and set the options for + // each if not given. + foreach ($paths as &$options) { + // Save the explicit standards. + $standard = $options['standard'] ?? []; + unset($options['standard']); + if (empty($options)) { + $options = $similar_options; + } + // Add the explicit standards back. + $options['standard'] = (array) $standard; + } + } + unset($options); + // Remove any path that does not actually exist. + foreach ($paths as $path => $options) { + if (!is_dir($path)) { + unset($paths[$path]); + } + } + if (empty($paths)) { + $this->printError( + 'Unable to find any folders to run coding standards checks on.' + ); + + return new ResultData(ResultData::EXITCODE_ERROR); + } + $one_failed = false; + foreach ($paths as $path => $path_options) { + // If standards is not given for a path, use the --standards option. + if (empty($path_options['standard'])) { + $standards = (array) $standards; + } else { + $standards = (array) $path_options['standard']; + } + // This is a virtual option since phpcs only supports one + // standard at a time. + unset($path_options['standard']); + foreach ($standards as $standard) { + $success = $this->taskExec('./vendor/bin/phpcs') + ->options($path_options, '=') + ->option('standard', $standard, '=') + ->arg($path) + ->run()->wasSuccessful(); + if (!$one_failed && !$success) { + $one_failed = true; + } + } + } + if ($one_failed) { + $this->printError( + 'ERROR: Coding standards failed, please see the output above.' + ); + + return new ResultData(ResultData::EXITCODE_ERROR); + } else { + $this->sayWithWrapper('SUCCESS: Coding standards passed.'); + + return new ResultData(); + } + } + + /** + * Ensure that the 'composer.lock' file is valid. + * + * @command validate:composer-lock + * + * @option string $composer-path The path to the composer binary. + * @option string $working-directory In case composer is not in the same + * directory as RoboFile.php. + * + * @return \Robo\ResultData + */ + public function validateComposerLock( + array $opts = [ + 'composer-path' => 'vendor/bin/composer', + 'working-directory' => './', + ] + ): ResultData { + [ + $composer_path, + $working_directory, + ] = $this->getOptions(['composer-path', 'working-directory'], $opts); + $this->sayWithWrapper('Ensuring valid composer.lock file.'); + if (!$this->taskExec($composer_path) + ->arg('validate') + ->option('working-dir', $working_directory) + ->option('no-check-all') + ->option('no-check-publish') + ->option('no-interaction') + ->run()->wasSuccessful()) { + $this->printError( + sprintf( + 'Composer lock is invalid, please see the output above. Also note, that running "%s update' . + ' --lock" should be able to help without updating all dependencies.', + $opts['composer-path'] + ) + ); + + return new ResultData(ResultData::EXITCODE_ERROR); + } + $this->sayWithWrapper('SUCCESS: composer.lock is valid.'); + + return new ResultData(); + } + + /** + * Validate the text of all commit messages missing from $target-branch. + * + * @command validate:commit-messages + * + * @option string $project-id Used as a token replacement in + * pattern. Defaults to ''. + * @option string $target-branch The branch that these commits will be + * merged into. Defaults to 'develop'. + * @option string $git-remote The name of the git remote to fetch the latest + * commits on $target-branch. + * @option string pattern The regular expression to use validate all + * commits not on $target-branch. {$project_id} can be used as token in + * this value. + * @option string $short-help A short description when invalid commits are + * found. {$project_id} can be used as a token. + * @option string[] $long-help An array of strings shown to help describe + * how exactly commit messages should be. {$project_id} can be used as a + * token in any of these values. + * + * @return \Robo\ResultData + */ + public function validateCommitMessages( + array $opts = [ + 'project-id' => '', + 'target-branch' => 'develop', + 'git-remote' => 'origin', + 'pattern' => '/^{$project_id}-(\d{4}): /', + 'short-help' => 'Commit messages must start with: \'{$project_id}-x:y\'', + 'long-help' => [ + 'Where x is the ticket number.', + 'And y is a space.', + '', + 'How do I update my commit messages?', + 'See https://www.atlassian.com/git/tutorials/rewriting-history', + '', + 'After re-rewriting the history, you should --force-with-lease the push.', + 'https://stackoverflow.com/questions/52823692/git-push-force-with-lease-vs-force', + ], + ] + ): ResultData { + [ + $project_id, + $long_help, + ] = $this->getOptions(['project-id', 'long-help'], $opts, false); + [ + $target_branch, + $git_remote, + $pattern, + $short_help, + ] = $this->getOptions( + [ + 'target-branch', + 'git-remote', + 'pattern', + 'short-help', + ], + $opts + ); + $replace = static function ($subject) use ($project_id) { + return str_replace( + '{$project_id}', + preg_quote($project_id), + $subject + ); + }; + $this->sayWithWrapper( + "Validating commit messages to be added to '$target_branch'" + ); + // Fetch the latest in the target branch. + if (!$this->_exec( + "git fetch $git_remote $target_branch:refs/remotes/$git_remote/$target_branch" + )->wasSuccessful()) { + $this->printError('Unable to fetch the target branch.'); + + return new ResultData(ResultData::EXITCODE_ERROR); + } + $git_command = "git log $git_remote/$target_branch...HEAD --pretty=format:%s --no-merges"; + exec($git_command, $output, $result_code); + if ($result_code !== 0) { + $this->printError('Unable to git log data.'); + + return new ResultData(ResultData::EXITCODE_ERROR); + } + $bad_commits = []; + foreach ($output as $git_message) { + // Allow $project_id to be used as a token in the message pattern. + if (!preg_match($replace($pattern), $git_message)) { + $bad_commits[] = $git_message; + } + } + if (!empty($bad_commits)) { + $this->printError('The following commits are not valid:'); + foreach ($bad_commits as $bad_commit) { + $this->say($bad_commit); + } + $short_help = $replace($short_help); + $this->sayWithWrapper(" $short_help "); + foreach ($long_help as $item) { + $this->say($replace($item)); + } + $this->say(''); + $this->say('To see all the commits on your branch:'); + $this->say($git_command); + + return new ResultData(ResultData::EXITCODE_ERROR); + } + $this->sayWithWrapper('SUCCESS: Commit messages are valid.'); + + return new ResultData(); + } + + /** + * Validate a branch name. + * + * @command validate:branch-name + * + * @arg string $branch_name The branch name to validate. If not given it + * the current branch will attempt to be detected. + * + * @option string $project-id Used as a token replacement in $pattern. + * Defaults to ''. + * @option string $pattern The regular expression to use validate + * $branch_name. {$project_id} can be used as token in this value. + * @option string custom-help A help message when the 'custom' type branch + * is invalid. {$project_id} can be used as token in this value. + * @option string[] $valid-branch-names These are all branch names that will + * be valid. + * + * @return \Robo\ResultData + */ + public function validateBranchName( + string $branch_name = '', + array $opts = [ + 'project-id' => '', + 'pattern' => '/^feature\/{$project_id}-([\d]{1,})-(?!.*--)([a-z\d]{1})([a-z\d-]{3,})([a-z\d]{1})$/', + 'custom-help' => [ + 'feature/{$project_id}-* where * can be:', + ' - Always lower case.', + ' - Starts and end with a letter or integer.', + ' - Contains letters, integers, or dashes (non-consecutive).', + ' - At least 5 characters long.', + ], + 'valid-branch-names' => [ + // Matches a branch named 'develop'. + 'explicit|develop', + // Matches a branch named 'main'. + 'explicit|main', + // Matches a custom regular expression found in $pattern. + 'custom|', + // Matches a branch like: hotfix/2.1.3. + 'semantic|hotfix', + // Matches a branch like (the last number MUST be a 0): release/2.1.0. + 'semantic_end_0|release', + ], + ] + ): ResultData { + $this->sayWithWrapper("Validating branch name"); + [ + $project_id, + $custom_help, + ] = $this->getOptions(['project-id', 'custom-help'], $opts, false); + [ + $pattern, + $valid_branch_names, + ] = $this->getOptions( + [ + 'pattern', + 'valid-branch-names', + ], + $opts + ); + + if ($branch_name === '') { + $branch_name = $this->getGitBranch(); + if (null === $branch_name) { + $this->printError( + 'A Git branch cannot be automatically detected. Please pass as the first argument.' + ); + + return new ResultData(ResultData::EXITCODE_ERROR); + } + } + $replace = static function ( + string $subject, + bool $quote + ) use ( + $project_id + ) { + if ($quote) { + $project_id = preg_quote($project_id); + } + + return str_replace('{$project_id}', $project_id, $subject); + }; + $table = new Table($this->output()); + $table + ->setHeaders( + [ + 'Branch Type', + 'Help', + ] + ); + $help_rows = []; + $success = false; + // Test all the $valid_branch_names against the current branch name. Each + // valid branch name does not throw an error if it does not succeed, because + // we don't know which of the valid branch name types it was trying to be. + // Instead, stop on a successful match of any valid branch type then show + // the help for all types that are valid. + foreach ($valid_branch_names as $valid_branch_name) { + [$type, $value] = explode('|', $valid_branch_name); + switch ($type) { + case 'explicit': + $help_rows[] = [ + $type, + "The branch name: $value", + ]; + if ($value === $branch_name) { + $success = true; + break 2; + } + break; + + // This 'custom' type lets you match the branch name against a custom + // regular expression ($pattern). + case 'custom': + if (empty(array_filter($custom_help))) { + $this->printError("If 'custom' branch type is used then --custom-help must be given. Otherwise," + . " pass custom --valid-branch-names without 'custom'."); + return new ResultData(ResultData::EXITCODE_ERROR); + } + foreach ($custom_help as &$item) { + $item = $replace($item, false); + } + $help_rows[] = [ + $type, + implode("\n", $custom_help), + ]; + if (preg_match($replace($pattern, true), $branch_name)) { + $success = true; + break 2; + } + break; + + case 'semantic': + case 'semantic_end_0': + $semantic_help = [ + "\"$value/*\" where * can be:", + " - Start with an integer greater than 0.", + " - Then a period.", + " - Then an integer.", + " - Then a period.", + $type === 'semantic_end_0' ? ' - Then 0.' : ' - Then an integer greater than 0.', + ]; + $help_rows[] = [ + $type, + implode("\n", $semantic_help), + ]; + $value = preg_quote($value); + $end = $type === 'semantic_end_0' ? '0' : '([1-9]+)'; + if (preg_match( + "/^$value\/(\d+)\.(\d+)\.$end$/", + $branch_name + )) { + $success = true; + break 2; + } + break; + + default: + $this->printError("$type is not a valid branch type"); + break 2; + } + } + + if (!$success) { + $this->printError( + "Invalid branch name '$branch_name'" + ); + + $table->setRows($help_rows); + $table->render(); + return new ResultData(ResultData::EXITCODE_ERROR); + } + $this->sayWithWrapper("SUCCESS: Branch name '$branch_name' is valid."); + + return new ResultData(); + } +}