Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
134 / 134
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
SquashCommand
100.00% covered (success)
100.00%
134 / 134
100.00% covered (success)
100.00%
4 / 4
41
100.00% covered (success)
100.00%
1 / 1
 configure
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 deduplicateChanges2
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 mergeChanges
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 execute
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
1 / 1
29
1<?php
2/**
3 * "Squash" command for the changelogger tool CLI.
4 *
5 * @package automattic/jetpack-changelogger
6 */
7
8namespace Automattic\Jetpack\Changelogger;
9
10use Automattic\Jetpack\Changelog\ChangeEntry;
11use InvalidArgumentException;
12use Symfony\Component\Console\Attribute\AsCommand;
13use Symfony\Component\Console\Input\InputInterface;
14use Symfony\Component\Console\Input\InputOption;
15use 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' )]
21class 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'
53The <info>squash</info> command combines multiple existing changelog entries into one.
54
55The default behavior is to combine all entries that differ from the first only by prerelease
56and/or buildinfo. If <info>--regex</info> is used, instead all entries up to the first not matching
57the regex are merged. If <info>--count</info> is used, instead that many entries are merged.
58
59The default behavior is to combine the entries under the version number, date, and link of the
60first 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>,
62and <info>--epilogue</info>.
63EOF
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}