Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
299 / 299
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
WriteCommand
100.00% covered (success)
100.00%
299 / 299
100.00% covered (success)
100.00%
14 / 14
99
100.00% covered (success)
100.00%
1 / 1
 configure
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 askToContinue
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 loadChangelog
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 addEntry
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 writeChangelog
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 deleteChanges
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 loadChanges
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
10
 deduplicateChanges
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
10
 doChangesHaveContent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 doAmendChanges
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 sortChanges
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getUseVersion
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
17
 nextVersion
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
11
 execute
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
13
1<?php
2/**
3 * "Write" 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 Automattic\Jetpack\Changelog\Changelog;
12use InvalidArgumentException;
13use Symfony\Component\Console\Attribute\AsCommand;
14use Symfony\Component\Console\Command\Command;
15use Symfony\Component\Console\Exception\MissingInputException;
16use Symfony\Component\Console\Helper\QuestionHelper;
17use Symfony\Component\Console\Input\InputInterface;
18use Symfony\Component\Console\Input\InputOption;
19use Symfony\Component\Console\Output\OutputInterface;
20use Symfony\Component\Console\Question\ChoiceQuestion;
21use Symfony\Component\Console\Question\ConfirmationQuestion;
22
23/**
24 * "Write" command for the changelogger tool CLI.
25 */
26#[AsCommand( 'write', 'Updates the changelog from change files' )]
27class WriteCommand extends Command {
28
29    const OK_EXIT            = 0;
30    const NO_CHANGE_EXIT     = 1;
31    const ASKED_EXIT         = 2;
32    const FATAL_EXIT         = 3;
33    const DELETE_FAILED_EXIT = 4;
34
35    /**
36     * The default command name.
37     *
38     * @var string|null
39     */
40    protected static $defaultName = 'write';
41
42    /**
43     * The default command description
44     *
45     * @var string|null
46     */
47    protected static $defaultDescription = 'Updates the changelog from change files';
48
49    /**
50     * The FormatterPlugin in use.
51     *
52     * @var FormatterPlugin
53     */
54    protected $formatter;
55
56    /**
57     * The VersioningPlugin in use.
58     *
59     * @var VersioningPlugin
60     */
61    protected $versioning;
62
63    /**
64     * Whether we already asked about there being no changes.
65     *
66     * @var bool
67     */
68    protected $askedNoChanges = false;
69
70    /**
71     * Configures the command.
72     */
73    protected function configure() {
74        $this->setDescription( static::$defaultDescription )
75            ->addOption( 'amend', null, InputOption::VALUE_NONE, 'Amend the latest version instead of creating a new one' )
76            ->addOption( 'yes', null, InputOption::VALUE_NONE, 'Default all questions to "yes" instead of "no". Particularly useful for non-interactive mode' )
77            ->addOption( 'use-version', null, InputOption::VALUE_REQUIRED, 'Use this version instead of determining the version automatically' )
78            ->addOption( 'use-significance', null, InputOption::VALUE_REQUIRED, 'When determining the new version, use this significance instead of using the actual change files' )
79            ->addOption( 'prerelease', 'p', InputOption::VALUE_REQUIRED, 'When determining the new version, include this prerelease suffix' )
80            ->addOption( 'buildinfo', 'b', InputOption::VALUE_REQUIRED, 'When fetching the next version, include this buildinfo suffix' )
81            ->addOption( 'release-date', null, InputOption::VALUE_REQUIRED, 'Release date, as a valid PHP date or "unreleased"', 'now' )
82            ->addOption( 'default-first-version', null, InputOption::VALUE_NONE, 'If the changelog is currently empty, guess a "first" version instead of erroring' )
83            ->addOption( 'deduplicate', null, InputOption::VALUE_REQUIRED, 'Deduplicate new changes against the last N versions. Set -1 to disable deduplication entirely.', 0 )
84            ->addOption( 'prologue', null, InputOption::VALUE_REQUIRED, 'Prologue text for the new changelog entry' )
85            ->addOption( 'epilogue', null, InputOption::VALUE_REQUIRED, 'Epilogue text for the new changelog entry' )
86            ->addOption( 'link', null, InputOption::VALUE_REQUIRED, 'Link for the new changelog entry' )
87            ->addOption( 'add-pr-num', null, InputOption::VALUE_NONE, 'Try to append the GH PR number to each entry. Commit subject must end like: (#123)' )
88            ->setHelp(
89                <<<'EOF'
90The <info>write</info> command adds a new changelog entry based on the changes files, and removes the changes files.
91
92Various edge cases will interactively prompt for information if possible. Use <info>--no-interaction</info> to avoid
93this, along with <info>--yes</info> if you want to proceed through all prompts instead of stopping.
94
95Exit codes are:
96
97* 0: Success.
98* 1: No changes were found, and continuing wasn't forced.
99* 2: A non-fatal error was encountered and continuing wasn't forced.
100* 3: A fatal error was encountered.
101* 4: Changelog was successfully updated, but changes files could not be removed.
102EOF
103            );
104
105        try {
106            $this->getDefinition()->addOptions( Config::formatterPlugin()->getOptions() );
107        } catch ( \Exception $ex ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
108            // Will handle later.
109        }
110        try {
111            $this->getDefinition()->addOptions( Config::versioningPlugin()->getOptions() );
112        } catch ( \Exception $ex ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
113            // Will handle later.
114        }
115    }
116
117    /**
118     * Ask to continue.
119     *
120     * @param InputInterface  $input InputInterface.
121     * @param OutputInterface $output OutputInterface.
122     * @param string          $msg Situation being asked about.
123     * @return bool
124     */
125    private function askToContinue( InputInterface $input, OutputInterface $output, $msg ) {
126        $yes = (bool) $input->getOption( 'yes' );
127
128        if ( ! $input->isInteractive() ) {
129            if ( $yes ) {
130                $output->writeln( "<warning>$msg</> Continuing anyway." );
131                return true;
132            }
133            $output->writeln( "<error>$msg</>" );
134            return false;
135        }
136        try {
137            $question = new ConfirmationQuestion( "$msg Proceed? " . ( $yes ? '[Y/n] ' : '[y/N] ' ), $yes );
138            $helper   = $this->getHelper( 'question' );
139            '@phan-var QuestionHelper $helper';
140            return $helper->ask( $input, $output, $question );
141        } catch ( MissingInputException $ex ) { // @codeCoverageIgnore
142            $output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
143            return false; // @codeCoverageIgnore
144        }
145    }
146
147    /**
148     * Load the changelog.
149     *
150     * @param InputInterface  $input InputInterface.
151     * @param OutputInterface $output OutputInterface.
152     * @return Changelog|int Changelog if everything went fine, or an exit code.
153     */
154    protected function loadChangelog( InputInterface $input, OutputInterface $output ) {
155        // Load changelog.
156        $file = Config::changelogFile();
157        if ( ! file_exists( $file ) ) {
158            if ( ! $this->askToContinue( $input, $output, "Changelog file $file does not exist!" ) ) {
159                return self::ASKED_EXIT;
160            }
161            $changelog = new Changelog();
162        } else {
163            $output->writeln( "Reading changelog from $file...", OutputInterface::VERBOSITY_DEBUG );
164            error_clear_last();
165            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
166            $contents = @file_get_contents( $file );
167            // @codeCoverageIgnoreStart
168            if ( ! is_string( $contents ) ) {
169                $err = error_get_last();
170                $output->writeln( "<error>Failed to read $file: {$err['message']}</>" );
171                return self::FATAL_EXIT;
172            }
173            // @codeCoverageIgnoreEnd
174            try {
175                $changelog = $this->formatter->parse( $contents );
176            } catch ( \Exception $ex ) {
177                $output->writeln( "<error>Failed to parse changelog: {$ex->getMessage()}</>" );
178                return self::FATAL_EXIT;
179            }
180        }
181        return $changelog;
182    }
183
184    /**
185     * Add the entry to the changelog.
186     *
187     * @param InputInterface  $input InputInterface.
188     * @param OutputInterface $output OutputInterface.
189     * @param Changelog       $changelog Changelog.
190     * @param string          $version Version.
191     * @param ChangeEntry[]   $changes Changes.
192     * @return int
193     */
194    protected function addEntry( InputInterface $input, OutputInterface $output, Changelog $changelog, $version, array $changes ) {
195        $output->writeln( 'Creating new changelog entry.', OutputInterface::VERBOSITY_DEBUG );
196        $data = array(
197            'prologue'  => (string) $input->getOption( 'prologue' ),
198            'epilogue'  => (string) $input->getOption( 'epilogue' ),
199            'link'      => $input->getOption( 'link' ),
200            'changes'   => $changes,
201            'timestamp' => (string) $input->getOption( 'release-date' ),
202        );
203        if ( null === $data['link'] && $changelog->getLatestEntry() ) {
204            $data['link'] = Config::link( $changelog->getLatestEntry()->getVersion(), $version );
205        }
206        if ( 'unreleased' === $data['timestamp'] ) {
207            $data['timestamp'] = null;
208        }
209        try {
210            $changelog->addEntry( $this->formatter->newChangelogEntry( $version, $data ) );
211        } catch ( InvalidArgumentException $ex ) {
212            $output->writeln( "<error>Failed to create changelog entry: {$ex->getMessage()}</>" );
213            return self::FATAL_EXIT;
214        }
215        return self::OK_EXIT;
216    }
217
218    /**
219     * Write the changelog.
220     *
221     * @param InputInterface  $input InputInterface.
222     * @param OutputInterface $output OutputInterface.
223     * @param Changelog       $changelog Changelog.
224     * @return int
225     */
226    protected function writeChangelog( InputInterface $input, OutputInterface $output, Changelog $changelog ) {
227        $file = Config::changelogFile();
228        $output->writeln( "Writing changelog to $file...", OutputInterface::VERBOSITY_DEBUG );
229        try {
230            $contents = $this->formatter->format( $changelog );
231        } catch ( InvalidArgumentException $ex ) {
232            $output->writeln( "<error>Failed to write the changelog: {$ex->getMessage()}</>" );
233            return self::FATAL_EXIT;
234        }
235
236        error_clear_last();
237        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
238        $ok = @file_put_contents( $file, $contents );
239        if ( strlen( $contents ) !== $ok ) {
240            $err = error_get_last();
241            $output->writeln( "<error>Failed to write $file: {$err['message']}</>" );
242            return self::FATAL_EXIT;
243        }
244
245        return self::OK_EXIT;
246    }
247
248    /**
249     * Delete the change files.
250     *
251     * @param InputInterface  $input InputInterface.
252     * @param OutputInterface $output OutputInterface.
253     * @param array           $files Files returned from `loadChanges()`.
254     * @return int
255     */
256    protected function deleteChanges( InputInterface $input, OutputInterface $output, array $files ) {
257        $dir = Config::changesDir();
258        $ret = self::OK_EXIT;
259        foreach ( $files as $name => $flag ) {
260            if ( $flag >= 2 ) {
261                continue;
262            }
263            error_clear_last();
264            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
265            $ok = @unlink( $dir . DIRECTORY_SEPARATOR . $name );
266            if ( $ok ) {
267                $output->writeln( "Deleted change file $name.", OutputInterface::VERBOSITY_DEBUG );
268            } else {
269                $err = error_get_last();
270                $output->writeln( "<warning>Failed to delete $name: {$err['message']}" );
271                $ret = self::DELETE_FAILED_EXIT;
272            }
273        }
274        return $ret;
275    }
276
277    /**
278     * Load the changes.
279     *
280     * @param InputInterface  $input InputInterface.
281     * @param OutputInterface $output OutputInterface.
282     * @return array Array of [ $code, $changes, $files ].
283     */
284    protected function loadChanges( InputInterface $input, OutputInterface $output ) {
285        $dir = Config::changesDir();
286        if ( ! is_dir( $dir ) ) {
287            $this->askedNoChanges = true;
288            if ( ! $this->askToContinue( $input, $output, 'Changes directory does not exist, so there are no changes to write!' ) ) {
289                return array( self::NO_CHANGE_EXIT, null, null );
290            }
291            return array( self::OK_EXIT, array(), array() );
292        }
293
294        $output->writeln( "Reading changes from $dir...", OutputInterface::VERBOSITY_DEBUG );
295        $files         = null; // Make phpcs happy.
296        $input_options = array(
297            'add-pr-num' => $input->getOption( 'add-pr-num' ),
298        );
299        $changes       = Utils::loadAllChanges( $dir, Config::types(), $this->formatter, $output, $files, $input_options );
300        $max           = $files ? max( $files ) : 0;
301        if ( $max > 0 && ! $this->askToContinue( $input, $output, ( $max > 1 ? 'Errors' : 'Warnings' ) . ' were encountered while reading changes!' ) ) {
302            return array( self::ASKED_EXIT, null, null );
303        }
304        if ( ! $changes && ! $this->askedNoChanges ) {
305            $this->askedNoChanges = true;
306            if ( ! $this->askToContinue( $input, $output, 'No changes were found!' ) ) {
307                return array( self::NO_CHANGE_EXIT, null, null );
308            }
309        }
310        return array( self::OK_EXIT, $changes, $files );
311    }
312
313    /**
314     * Deduplicate changes.
315     *
316     * @param InputInterface  $input InputInterface.
317     * @param OutputInterface $output OutputInterface.
318     * @param Changelog       $changelog Changelog.
319     * @param ChangeEntry[]   $changes Changes.
320     * @return int
321     */
322    protected function deduplicateChanges( InputInterface $input, OutputInterface $output, Changelog $changelog, array &$changes ) {
323        // Deduplicate changes.
324        if ( ! $changes ) {
325            $output->writeln( 'Skipping deduplication, there are no changes.', OutputInterface::VERBOSITY_DEBUG );
326            return self::OK_EXIT;
327        }
328
329        $depth = (int) $input->getOption( 'deduplicate' );
330        if ( $depth < 0 ) {
331            $output->writeln( "Skipping deduplication, --deduplicate is $depth.", OutputInterface::VERBOSITY_DEBUG );
332            return self::OK_EXIT;
333        }
334
335        $dedup = array();
336        if ( $depth > 0 ) {
337            $output->writeln( "Deduplicating changes from the last $depth version(s)...", OutputInterface::VERBOSITY_DEBUG );
338            foreach ( array_slice( $changelog->getEntries(), 0, $depth ) as $entry ) {
339                foreach ( $entry->getChanges() as $change ) {
340                    $dedup[ $change->getContent() ] = true;
341                }
342            }
343            unset( $dedup[''] );
344        } else {
345            $output->writeln( 'Deduplicating changes in the current version...', OutputInterface::VERBOSITY_DEBUG );
346        }
347        $changes = array_filter(
348            $changes,
349            function ( $change, $name ) use ( &$dedup, $output ) {
350                if ( isset( $dedup[ $change->getContent() ] ) ) {
351                    $output->writeln( "Found duplicate change in $name.", OutputInterface::VERBOSITY_DEBUG );
352                    return false;
353                }
354                $dedup[ $change->getContent() ] = true;
355                return true;
356            },
357            ARRAY_FILTER_USE_BOTH
358        );
359        if ( ! $changes && ! $this->askedNoChanges ) {
360            $this->askedNoChanges = true;
361            if ( ! $this->askToContinue( $input, $output, 'All changes were duplicates.' ) ) {
362                return self::NO_CHANGE_EXIT;
363            }
364        }
365        return self::OK_EXIT;
366    }
367
368    /**
369     * Check whether any changes have content.
370     *
371     * @param InputInterface  $input InputInterface.
372     * @param OutputInterface $output OutputInterface.
373     * @param ChangeEntry[]   $changes Changes.
374     * @return bool
375     */
376    protected function doChangesHaveContent( InputInterface $input, OutputInterface $output, array $changes ) {
377        $output->writeln( 'Checking if any changes have content...', OutputInterface::VERBOSITY_DEBUG );
378        foreach ( $changes as $name => $change ) {
379            if ( $change->getContent() !== '' ) {
380                $output->writeln( "Yes, $name has content.", OutputInterface::VERBOSITY_DEBUG );
381                return true;
382            }
383        }
384        return false;
385    }
386
387    /**
388     * Apply --amend if applicable.
389     *
390     * @param InputInterface  $input InputInterface.
391     * @param OutputInterface $output OutputInterface.
392     * @param Changelog       $changelog Changelog.
393     * @param ChangeEntry[]   $changes Changes.
394     * @param string|null     $amendedVersion Set to indicate the source version of the amend, if any.
395     * @return int
396     */
397    protected function doAmendChanges( InputInterface $input, OutputInterface $output, Changelog $changelog, array &$changes, &$amendedVersion = null ) {
398        $amendedVersion = null;
399        if ( $input->getOption( 'amend' ) ) {
400            $entries = $changelog->getEntries();
401            if ( $entries ) {
402                $latest = array_shift( $entries );
403                $changelog->setEntries( $entries );
404                $amendedVersion = $latest->getVersion();
405                $changes        = array_merge( $latest->getChanges(), array_values( $changes ) );
406                $output->writeln( "Removing changes for $amendedVersion from changelog for --amend.", OutputInterface::VERBOSITY_DEBUG );
407
408                if ( $input->getOption( 'prologue' ) === null ) {
409                    $input->setOption( 'prologue', $latest->getPrologue() );
410                }
411                if ( $input->getOption( 'epilogue' ) === null ) {
412                    $input->setOption( 'epilogue', $latest->getEpilogue() );
413                }
414                if ( $input->getOption( 'link' ) === null ) {
415                    $oldLink     = $latest->getLink();
416                    $defaultLink = $changelog->getLatestEntry() ? Config::link( $changelog->getLatestEntry()->getVersion(), $latest->getVersion() ) : null;
417                    if ( $oldLink !== $defaultLink ) {
418                        $input->setOption( 'link', $latest->getLink() );
419                    }
420                }
421            } else {
422                $output->writeln( 'No version to amend, ignoring --amend.', OutputInterface::VERBOSITY_DEBUG );
423            }
424        }
425        return self::OK_EXIT;
426    }
427
428    /**
429     * Sort changes.
430     *
431     * @param ChangeEntry[] $changes Changes.
432     * @return array
433     */
434    protected function sortChanges( array $changes ) {
435        $sortConfig = array(
436            'ordering'         => Config::ordering(),
437            'knownSubheadings' => Config::types(),
438        );
439        $changes    = array_values( $changes );
440        usort(
441            $changes,
442            function ( $a, $b ) use ( $sortConfig, $changes ) {
443                $ret = ChangeEntry::compare( $a, $b, $sortConfig );
444                if ( 0 === $ret ) {
445                    // Stability.
446                    $ret = array_search( $a, $changes, true ) <=> array_search( $b, $changes, true );
447                }
448                return $ret;
449            }
450        );
451        return $changes;
452    }
453
454    /**
455     * Get the version from the command line.
456     *
457     * @param InputInterface  $input InputInterface.
458     * @param OutputInterface $output OutputInterface.
459     * @param Changelog       $changelog Changelog.
460     * @return string|int New version, or int on error.
461     */
462    protected function getUseVersion( InputInterface $input, OutputInterface $output, Changelog $changelog ) {
463        $version = $input->getOption( 'use-version' );
464        $output->writeln( "Using version $version from command line.", OutputInterface::VERBOSITY_DEBUG );
465
466        // Normalize it?
467        try {
468            $nversion = $this->versioning->normalizeVersion( $version );
469        } catch ( InvalidArgumentException $ex ) {
470            $nversion = $version;
471            $output->writeln( "<error>Invalid --use-version: {$ex->getMessage()}</>" );
472            if ( ! $this->askToContinue( $input, $output, 'The specified version is not valid. This may cause problems in the future!' ) ) {
473                return self::ASKED_EXIT;
474            }
475        }
476        if ( $version !== $nversion ) {
477            if ( ! $input->isInteractive() ) {
478                if ( ! $this->askToContinue( $input, $output, "The supplied version $version is not normalized, it should be $nversion." ) ) {
479                    return self::ASKED_EXIT;
480                }
481            } else {
482                try {
483                    $question = new ChoiceQuestion(
484                        "The supplied version $version is not normalized.",
485                        array(
486                            'proceed'   => "Proceed with $version",
487                            'normalize' => "Normalize to $nversion",
488                            'abort'     => 'Abort',
489                        ),
490                        $input->getOption( 'yes' ) ? 'proceed' : 'abort'
491                    );
492
493                    $helper = $this->getHelper( 'question' );
494                    '@phan-var QuestionHelper $helper';
495                    switch ( $helper->ask( $input, $output, $question ) ) {
496                        case 'proceed': // @codeCoverageIgnore
497                            break;
498                        case 'normalize': // @codeCoverageIgnore
499                            $output->writeln( "Normalizing $version to $nversion.", OutputInterface::VERBOSITY_DEBUG );
500                            $version = $nversion;
501                            break;
502                        default:
503                            return self::ASKED_EXIT;
504                    }
505                } catch ( MissingInputException $ex ) { // @codeCoverageIgnore
506                    $output->writeln( 'Got EOF when attempting to query user, aborting.', OutputInterface::VERBOSITY_VERBOSE ); // @codeCoverageIgnore
507                    return self::ASKED_EXIT; // @codeCoverageIgnore
508                }
509            }
510        }
511
512        // Check that it's newer than the current version.
513        $latest = $changelog->getLatestEntry();
514        if ( $latest ) {
515            $curver = $latest->getVersion();
516            try {
517                $cmp = $this->versioning->compareVersions( $version, $curver );
518            } catch ( InvalidArgumentException $ex ) {
519                $output->writeln( "Cannot compare $version with $curver: {$ex->getMessage()}", OutputInterface::VERBOSITY_DEBUG );
520                $cmp = 1;
521            }
522            if ( $cmp < 0 && ! $this->askToContinue( $input, $output, "The most recent version in the changelog is $curver, which comes after $version." ) ) {
523                return self::ASKED_EXIT;
524            }
525            if ( 0 === $cmp && ! $this->askToContinue( $input, $output, "The most recent version in the changelog is $curver, which is equivalent to $version." ) ) {
526                return self::ASKED_EXIT;
527            }
528        }
529        return $version;
530    }
531
532    /**
533     * Determine the next version.
534     *
535     * @param InputInterface  $input InputInterface.
536     * @param OutputInterface $output OutputInterface.
537     * @param Changelog       $changelog Changelog.
538     * @param ChangeEntry[]   $changes Changes.
539     * @param string|null     $amendedVersion The source version of the amend, if any.
540     * @return string|int New version, or int on error.
541     */
542    protected function nextVersion( InputInterface $input, OutputInterface $output, Changelog $changelog, array $changes, $amendedVersion ) {
543        $extra = array(
544            'prerelease' => $input->getOption( 'prerelease' ),
545            'buildinfo'  => $input->getOption( 'buildinfo' ),
546        );
547
548        // Is there a version in the changelog?
549        $latest = $changelog->getLatestEntry();
550        if ( ! $latest ) {
551            if ( null !== $amendedVersion ) {
552                $output->writeln( "Amending earliest version, reusing version $amendedVersion...", OutputInterface::VERBOSITY_DEBUG );
553                return $amendedVersion;
554            } elseif ( $input->getOption( 'default-first-version' ) ) {
555                return $this->versioning->firstVersion( $extra );
556            } else {
557                $output->writeln( '<error>Changelog file contains no entries! Use --use-version to specify the initial version.</>' );
558                return self::FATAL_EXIT;
559            }
560        }
561
562        $output->writeln( "Latest version from changelog is {$latest->getVersion()}.", OutputInterface::VERBOSITY_DEBUG );
563
564        // If they overrode the significance, use that. Otherwise use `$changes`.
565        if ( $input->getOption( 'use-significance' ) ) {
566            try {
567                $verchanges = array(
568                    $this->formatter->newChangeEntry(
569                        array(
570                            'significance' => $input->getOption( 'use-significance' ),
571                            'content'      => 'Dummy',
572                        )
573                    ),
574                );
575            } catch ( \Exception $ex ) {
576                $output->writeln( "<error>{$ex->getMessage()}</>" );
577                return self::FATAL_EXIT;
578            }
579        } else {
580            $verchanges = $changes;
581        }
582
583        // Get the next version from the versioning plugin.
584        try {
585            $version = VersionCommand::getNextVersion( $changelog->getVersions(), $verchanges, $extra );
586        } catch ( InvalidArgumentException $ex ) {
587            // Was it the version from the changelog that made it fail, or something else?
588            try {
589                $this->versioning->normalizeVersion( $latest->getVersion() );
590                $output->writeln( "<error>Failed to determine new version: {$ex->getMessage()}</>" );
591            } catch ( InvalidArgumentException $ex2 ) {
592                $output->writeln( "<error>Changelog file contains invalid version {$latest->getVersion()}! Use --use-version to specify the new version.</>" );
593            }
594            return self::FATAL_EXIT;
595        }
596        $output->writeln( "Next version is {$version}.", OutputInterface::VERBOSITY_DEBUG );
597
598        // When amending, if the next version turns out to be before the amended version, use the amended version.
599        try {
600            if ( null !== $amendedVersion && $this->versioning->compareVersions( $amendedVersion, $version ) > 0 ) {
601                $output->writeln( "Amended version $amendedVersion is later, using that instead.", OutputInterface::VERBOSITY_DEBUG );
602                $version = $amendedVersion;
603            }
604        } catch ( InvalidArgumentException $ex ) {
605            $output->writeln( "Amended version $amendedVersion is was not valid. Hope it wasn't supposed to be later.", OutputInterface::VERBOSITY_DEBUG );
606        }
607
608        return $version;
609    }
610
611    /**
612     * Executes the command.
613     *
614     * @param InputInterface  $input InputInterface.
615     * @param OutputInterface $output OutputInterface.
616     * @return int 0 If everything went fine, or an exit code.
617     */
618    protected function execute( InputInterface $input, OutputInterface $output ): int {
619        try {
620            $this->formatter = Config::formatterPlugin();
621            $this->formatter->setIO( $input, $output );
622            $this->versioning = Config::versioningPlugin();
623            $this->versioning->setIO( $input, $output );
624        } catch ( \Exception $ex ) {
625            $output->writeln( "<error>{$ex->getMessage()}</>" );
626            return self::FATAL_EXIT;
627        }
628        $this->askedNoChanges = false;
629
630        // Get the changelog.
631        $changelog = $this->loadChangelog( $input, $output );
632        if ( is_int( $changelog ) ) {
633            return $changelog;
634        }
635
636        // Get the changes.
637        list( $ret, $changes, $files ) = $this->loadChanges( $input, $output );
638        if ( self::OK_EXIT !== $ret ) {
639            return $ret;
640        }
641        $ret = $this->deduplicateChanges( $input, $output, $changelog, $changes );
642        if ( self::OK_EXIT !== $ret ) {
643            return $ret;
644        }
645        $anyChangesWithContent = $this->doChangesHaveContent( $input, $output, $changes );
646        if ( ! $anyChangesWithContent && ! $this->askedNoChanges ) {
647            $this->askedNoChanges = true;
648            if ( ! $this->askToContinue( $input, $output, 'There are no changes with content for this write.' ) ) {
649                return self::NO_CHANGE_EXIT;
650            }
651        }
652        $amendedVersion = null; // Make phpcs happy.
653        $ret            = $this->doAmendChanges( $input, $output, $changelog, $changes, $amendedVersion );
654        if ( self::OK_EXIT !== $ret ) {
655            return $ret; // @codeCoverageIgnore
656        }
657        $changes = $this->sortChanges( $changes );
658
659        // Determine next version.
660        if ( $input->getOption( 'use-version' ) !== null ) {
661            $version = $this->getUseVersion( $input, $output, $changelog );
662        } else {
663            $version = $this->nextVersion( $input, $output, $changelog, $changes, $amendedVersion );
664        }
665        if ( is_int( $version ) ) {
666            return $version;
667        }
668
669        // Add the new changelog entry.
670        $ret = $this->addEntry( $input, $output, $changelog, $version, $changes );
671        if ( self::OK_EXIT !== $ret ) {
672            return $ret;
673        }
674
675        // Write the changelog.
676        $ret = $this->writeChangelog( $input, $output, $changelog );
677        if ( self::OK_EXIT !== $ret ) {
678            return $ret;
679        }
680
681        // Delete change files and return.
682        return $this->deleteChanges( $input, $output, $files );
683    }
684}