Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
134 / 134 |
|
100.00% |
4 / 4 |
CRAP | |
100.00% |
1 / 1 |
| SquashCommand | |
100.00% |
134 / 134 |
|
100.00% |
4 / 4 |
41 | |
100.00% |
1 / 1 |
| configure | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
| deduplicateChanges2 | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
| mergeChanges | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| execute | |
100.00% |
90 / 90 |
|
100.00% |
1 / 1 |
29 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * "Squash" command for the changelogger tool CLI. |
| 4 | * |
| 5 | * @package automattic/jetpack-changelogger |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Changelogger; |
| 9 | |
| 10 | use Automattic\Jetpack\Changelog\ChangeEntry; |
| 11 | use InvalidArgumentException; |
| 12 | use Symfony\Component\Console\Attribute\AsCommand; |
| 13 | use Symfony\Component\Console\Input\InputInterface; |
| 14 | use Symfony\Component\Console\Input\InputOption; |
| 15 | use Symfony\Component\Console\Output\OutputInterface; |
| 16 | |
| 17 | /** |
| 18 | * "Squash" command for the changelogger tool CLI. |
| 19 | */ |
| 20 | #[AsCommand( 'squash', 'Squash changelog entries in the changelog file' )] |
| 21 | class SquashCommand extends WriteCommand { |
| 22 | |
| 23 | /** |
| 24 | * The default command name. |
| 25 | * |
| 26 | * @var string|null |
| 27 | */ |
| 28 | protected static $defaultName = 'squash'; |
| 29 | |
| 30 | /** |
| 31 | * The default command description |
| 32 | * |
| 33 | * @var string|null |
| 34 | */ |
| 35 | protected static $defaultDescription = 'Squash changelog entries in the changelog file'; |
| 36 | |
| 37 | /** |
| 38 | * Configures the command. |
| 39 | */ |
| 40 | protected function configure() { |
| 41 | $this->setDescription( static::$defaultDescription ) |
| 42 | ->addOption( 'regex', null, InputOption::VALUE_REQUIRED, 'PCRE regex to match the versions to squash, including delimiters' ) |
| 43 | ->addOption( 'count', null, InputOption::VALUE_REQUIRED, 'Number of versions to squash' ) |
| 44 | ->addOption( 'use-version', null, InputOption::VALUE_REQUIRED, 'Use this version instead of determining the version automatically' ) |
| 45 | ->addOption( 'release-date', null, InputOption::VALUE_REQUIRED, 'Release date, as a valid PHP date or "unreleased"' ) |
| 46 | ->addOption( 'prologue', null, InputOption::VALUE_REQUIRED, 'Prologue text for the new changelog entry' ) |
| 47 | ->addOption( 'epilogue', null, InputOption::VALUE_REQUIRED, 'Epilogue text for the new changelog entry' ) |
| 48 | ->addOption( 'link', null, InputOption::VALUE_REQUIRED, 'Link for the new changelog entry' ) |
| 49 | ->addOption( '--no-deduplicate', null, InputOption::VALUE_NONE, 'Do not deduplicate the changes' ) |
| 50 | ->addOption( 'yes', null, InputOption::VALUE_NONE, 'Default all questions to "yes" instead of "no". Particularly useful for non-interactive mode' ) |
| 51 | ->setHelp( |
| 52 | <<<'EOF' |
| 53 | The <info>squash</info> command combines multiple existing changelog entries into one. |
| 54 | |
| 55 | The default behavior is to combine all entries that differ from the first only by prerelease |
| 56 | and/or buildinfo. If <info>--regex</info> is used, instead all entries up to the first not matching |
| 57 | the regex are merged. If <info>--count</info> is used, instead that many entries are merged. |
| 58 | |
| 59 | The default behavior is to combine the entries under the version number, date, and link of the |
| 60 | first entry, with prologue and epilogue texts concatenated. This may be overridden using |
| 61 | <info>--use-version</info>, <info>--release-date</info>, <info>--link</info>, <info>--prologue</info>, |
| 62 | and <info>--epilogue</info>. |
| 63 | EOF |
| 64 | ); |
| 65 | |
| 66 | try { |
| 67 | $this->getDefinition()->addOptions( Config::formatterPlugin()->getOptions() ); |
| 68 | } catch ( \Exception $ex ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch |
| 69 | // Will handle later. |
| 70 | } |
| 71 | try { |
| 72 | $this->getDefinition()->addOptions( Config::versioningPlugin()->getOptions() ); |
| 73 | } catch ( \Exception $ex ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch |
| 74 | // Will handle later. |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Deduplicate changes. |
| 80 | * |
| 81 | * @param InputInterface $input InputInterface. |
| 82 | * @param OutputInterface $output OutputInterface. |
| 83 | * @param ChangeEntry[] $changes Changes. |
| 84 | * @return int |
| 85 | */ |
| 86 | protected function deduplicateChanges2( InputInterface $input, OutputInterface $output, array &$changes ) { |
| 87 | // Deduplicate changes. |
| 88 | if ( ! $changes ) { |
| 89 | $output->writeln( 'Skipping deduplication, there are no changes.', OutputInterface::VERBOSITY_DEBUG ); |
| 90 | return self::OK_EXIT; |
| 91 | } |
| 92 | |
| 93 | if ( $input->getOption( 'no-deduplicate' ) ) { |
| 94 | $output->writeln( 'Skipping deduplication, --no-deduplicate was specified.', OutputInterface::VERBOSITY_DEBUG ); |
| 95 | return self::OK_EXIT; |
| 96 | } |
| 97 | |
| 98 | $output->writeln( 'Deduplicating changes...', OutputInterface::VERBOSITY_DEBUG ); |
| 99 | $seen = array(); |
| 100 | foreach ( $changes as $k => $change ) { |
| 101 | if ( isset( $seen[ $change->getContent() ] ) ) { |
| 102 | $output->writeln( "Found duplicate change '{$change->getContent()}'.", OutputInterface::VERBOSITY_DEBUG ); |
| 103 | unset( $changes[ $k ] ); |
| 104 | } else { |
| 105 | $seen[ $change->getContent() ] = true; |
| 106 | } |
| 107 | } |
| 108 | return self::OK_EXIT; |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Merge changes. |
| 113 | * |
| 114 | * @param ChangeEntry[] $a Changes. |
| 115 | * @param ChangeEntry[] $b Changes. |
| 116 | * @return ChangeEntry[] |
| 117 | */ |
| 118 | protected function mergeChanges( array $a, array $b ) { |
| 119 | $sortConfig = array( |
| 120 | 'ordering' => Config::ordering(), |
| 121 | 'knownSubheadings' => Config::types(), |
| 122 | ); |
| 123 | |
| 124 | $a = $this->sortChanges( $a ); |
| 125 | $b = $this->sortChanges( $b ); |
| 126 | $ret = array(); |
| 127 | while ( $a && $b ) { |
| 128 | if ( ChangeEntry::compare( $a[0], $b[0], $sortConfig ) <= 0 ) { |
| 129 | $ret[] = array_shift( $a ); |
| 130 | } else { |
| 131 | $ret[] = array_shift( $b ); |
| 132 | } |
| 133 | } |
| 134 | return array_merge( $ret, $a, $b ); |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Executes the command. |
| 139 | * |
| 140 | * @param InputInterface $input InputInterface. |
| 141 | * @param OutputInterface $output OutputInterface. |
| 142 | * @return int 0 If everything went fine, or an exit code. |
| 143 | */ |
| 144 | protected function execute( InputInterface $input, OutputInterface $output ): int { |
| 145 | try { |
| 146 | $this->formatter = Config::formatterPlugin(); |
| 147 | $this->formatter->setIO( $input, $output ); |
| 148 | $this->versioning = Config::versioningPlugin(); |
| 149 | $this->versioning->setIO( $input, $output ); |
| 150 | } catch ( \Exception $ex ) { |
| 151 | $output->writeln( "<error>{$ex->getMessage()}</>" ); |
| 152 | return self::FATAL_EXIT; |
| 153 | } |
| 154 | |
| 155 | // Get the changelog. |
| 156 | $changelog = $this->loadChangelog( $input, $output ); |
| 157 | if ( is_int( $changelog ) ) { |
| 158 | return $changelog; |
| 159 | } |
| 160 | $inEntries = $changelog->getEntries(); |
| 161 | if ( ! $inEntries ) { |
| 162 | $output->writeln( '<error>Changelog contains no entries, cannot squash</>' ); |
| 163 | return self::FATAL_EXIT; |
| 164 | } |
| 165 | |
| 166 | // Extract the entries to squash. |
| 167 | $entries = array(); |
| 168 | if ( $input->getOption( 'regex' ) !== null ) { |
| 169 | $regex = $input->getOption( 'regex' ); |
| 170 | $output->writeln( "Looking for entries matching regex $regex", OutputInterface::VERBOSITY_DEBUG ); |
| 171 | // @phan-suppress-next-line PhanInfiniteLoop -- Phan isn't realizing that the array_shift might make $inEntries falsey. |
| 172 | while ( $inEntries ) { |
| 173 | // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 174 | $ret = @preg_match( $regex, $inEntries[0]->getVersion() ); |
| 175 | if ( false === $ret ) { |
| 176 | $err = error_get_last()['message']; |
| 177 | if ( substr( $err, 0, 14 ) === 'preg_match(): ' ) { |
| 178 | $err = substr( $err, 14 ); |
| 179 | } |
| 180 | $output->writeln( "<error>Regex match failed: $err</>" ); |
| 181 | return self::FATAL_EXIT; |
| 182 | } elseif ( $ret ) { |
| 183 | $output->writeln( "Will squash version {$inEntries[0]->getVersion()}", OutputInterface::VERBOSITY_DEBUG ); |
| 184 | $entries[] = array_shift( $inEntries ); |
| 185 | } else { |
| 186 | $output->writeln( "No match at {$inEntries[0]->getVersion()}, stopping there", OutputInterface::VERBOSITY_DEBUG ); |
| 187 | break; |
| 188 | } |
| 189 | } |
| 190 | } elseif ( $input->getOption( 'count' ) !== null ) { |
| 191 | $count = $input->getOption( 'count' ); |
| 192 | if ( ! preg_match( '/^\d+$/', $count ) || 0 === (int) $count ) { |
| 193 | $output->writeln( '<error>Count must be a positive integer</>' ); |
| 194 | return self::FATAL_EXIT; |
| 195 | } |
| 196 | $output->writeln( "Squashing first $count entries", OutputInterface::VERBOSITY_DEBUG ); |
| 197 | $entries = array_splice( $inEntries, 0, $count ); |
| 198 | } else { |
| 199 | $entries[] = array_shift( $inEntries ); |
| 200 | try { |
| 201 | $version = $this->versioning->parseVersion( $entries[0]->getVersion() )['version']; |
| 202 | } catch ( InvalidArgumentException $ex ) { |
| 203 | $output->writeln( "<error>Cannot parse version number {$entries[0]->getVersion()}</>" ); |
| 204 | return self::FATAL_EXIT; |
| 205 | } |
| 206 | $output->writeln( "Looking for entries matching version $version", OutputInterface::VERBOSITY_DEBUG ); |
| 207 | $output->writeln( "Will squash version {$entries[0]->getVersion()}", OutputInterface::VERBOSITY_DEBUG ); |
| 208 | // @phan-suppress-next-line PhanInfiniteLoop -- Phan isn't realizing that the array_shift might make $inEntries falsey. |
| 209 | while ( $inEntries ) { |
| 210 | try { |
| 211 | $version2 = $this->versioning->parseVersion( $inEntries[0]->getVersion() )['version']; |
| 212 | } catch ( InvalidArgumentException $ex ) { |
| 213 | $output->writeln( "Cannot parse version number {$inEntries[0]->getVersion()}, stopping there", OutputInterface::VERBOSITY_DEBUG ); |
| 214 | break; |
| 215 | } |
| 216 | if ( $version2 === $version ) { |
| 217 | $output->writeln( "Will squash version {$inEntries[0]->getVersion()}", OutputInterface::VERBOSITY_DEBUG ); |
| 218 | $entries[] = array_shift( $inEntries ); |
| 219 | } else { |
| 220 | $output->writeln( "No match at {$inEntries[0]->getVersion()}, stopping there", OutputInterface::VERBOSITY_DEBUG ); |
| 221 | break; |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | if ( count( $entries ) <= 0 ) { |
| 226 | $output->writeln( '<error>No entries to squash</>' ); |
| 227 | return self::NO_CHANGE_EXIT; |
| 228 | } elseif ( count( $entries ) <= 1 ) { |
| 229 | $output->writeln( '<error>Only a single entry matched, not squashing</>' ); |
| 230 | return self::NO_CHANGE_EXIT; |
| 231 | } |
| 232 | $changelog->setEntries( $inEntries ); |
| 233 | |
| 234 | $changes = array(); |
| 235 | $prologues = array(); |
| 236 | $epilogues = array(); |
| 237 | foreach ( $entries as $e ) { |
| 238 | $changes = $this->mergeChanges( $changes, $e->getChanges() ); |
| 239 | if ( trim( $e->getPrologue() ) !== '' ) { |
| 240 | $prologues[] = trim( $e->getPrologue() ); |
| 241 | } |
| 242 | if ( trim( $e->getEpilogue() ) !== '' ) { |
| 243 | $epilogues[] = trim( $e->getEpilogue() ); |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | $ret = $this->deduplicateChanges2( $input, $output, $changes ); |
| 248 | if ( self::OK_EXIT !== $ret ) { |
| 249 | return $ret; // @codeCoverageIgnore |
| 250 | } |
| 251 | |
| 252 | // Determine next version. |
| 253 | if ( $input->getOption( 'use-version' ) !== null ) { |
| 254 | $version = $this->getUseVersion( $input, $output, $changelog ); |
| 255 | } else { |
| 256 | $version = $entries[0]->getVersion(); |
| 257 | } |
| 258 | if ( is_int( $version ) ) { |
| 259 | return $version; |
| 260 | } |
| 261 | |
| 262 | if ( $input->getOption( 'release-date' ) === null ) { |
| 263 | $input->setOption( 'release-date', $entries[0]->getTimestamp()->format( 'Y-m-d\TH:i:sO' ) ); |
| 264 | } |
| 265 | if ( $input->getOption( 'link' ) === null ) { |
| 266 | $input->setOption( 'link', $entries[0]->getLink() ); |
| 267 | } |
| 268 | if ( $input->getOption( 'prologue' ) === null ) { |
| 269 | $input->setOption( 'prologue', implode( "\n\n", $prologues ) ); |
| 270 | } |
| 271 | if ( $input->getOption( 'epilogue' ) === null ) { |
| 272 | $input->setOption( 'epilogue', implode( "\n\n", $epilogues ) ); |
| 273 | } |
| 274 | |
| 275 | // Add the new changelog entry. |
| 276 | $ret = $this->addEntry( $input, $output, $changelog, $version, $changes ); |
| 277 | if ( self::OK_EXIT !== $ret ) { |
| 278 | return $ret; |
| 279 | } |
| 280 | |
| 281 | // Write the changelog. |
| 282 | return $this->writeChangelog( $input, $output, $changelog ); |
| 283 | } |
| 284 | } |