From 3454616467dec4be832c9e432d9788dabe652091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 29 Apr 2026 20:39:26 -0300 Subject: [PATCH 1/2] Bootstrap standalone changelog package --- .docheader | 13 ++ .editorconfig | 15 ++ .gitattributes | 12 ++ .gitignore | 10 + AGENTS.md | 29 +++ CHANGELOG.md | 12 ++ LICENSE | 21 ++ README.md | 107 +++++++++- bin/changelog | 37 ++++ composer.json | 80 ++++++++ docs/index.rst | 28 +++ phpunit.xml.dist | 14 ++ src/Console/Changelog.php | 64 ++++++ src/Console/Command/ChangelogEntryCommand.php | 119 +++++++++++ .../Command/ChangelogPromoteCommand.php | 96 +++++++++ .../ChangelogReleaseNotesRenderCommand.php | 101 ++++++++++ .../ChangelogVersionResolveCommand.php | 100 ++++++++++ src/Document/ChangelogDocument.php | 187 ++++++++++++++++++ src/Document/ChangelogRelease.php | 112 +++++++++++ src/Entry/ChangelogEntryType.php | 47 +++++ src/Filesystem/PackageFilesystem.php | 68 +++++++ src/Git/GitRepositoryUrlResolver.php | 48 +++++ src/Manager/ChangelogManager.php | 124 ++++++++++++ src/Parser/ChangelogParser.php | 109 ++++++++++ src/Renderer/MarkdownRenderer.php | 184 +++++++++++++++++ tests/Console/ChangelogTest.php | 42 ++++ tests/Document/ChangelogDocumentTest.php | 115 +++++++++++ tests/Entry/ChangelogEntryTypeTest.php | 61 ++++++ tests/Manager/ChangelogManagerTest.php | 138 +++++++++++++ tests/Parser/ChangelogParserTest.php | 116 +++++++++++ tests/Renderer/MarkdownRendererTest.php | 108 ++++++++++ 31 files changed, 2316 insertions(+), 1 deletion(-) create mode 100644 .docheader create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100755 bin/changelog create mode 100644 composer.json create mode 100644 docs/index.rst create mode 100644 phpunit.xml.dist create mode 100644 src/Console/Changelog.php create mode 100644 src/Console/Command/ChangelogEntryCommand.php create mode 100644 src/Console/Command/ChangelogPromoteCommand.php create mode 100644 src/Console/Command/ChangelogReleaseNotesRenderCommand.php create mode 100644 src/Console/Command/ChangelogVersionResolveCommand.php create mode 100644 src/Document/ChangelogDocument.php create mode 100644 src/Document/ChangelogRelease.php create mode 100644 src/Entry/ChangelogEntryType.php create mode 100644 src/Filesystem/PackageFilesystem.php create mode 100644 src/Git/GitRepositoryUrlResolver.php create mode 100644 src/Manager/ChangelogManager.php create mode 100644 src/Parser/ChangelogParser.php create mode 100644 src/Renderer/MarkdownRenderer.php create mode 100644 tests/Console/ChangelogTest.php create mode 100644 tests/Document/ChangelogDocumentTest.php create mode 100644 tests/Entry/ChangelogEntryTypeTest.php create mode 100644 tests/Manager/ChangelogManagerTest.php create mode 100644 tests/Parser/ChangelogParserTest.php create mode 100644 tests/Renderer/MarkdownRendererTest.php diff --git a/.docheader b/.docheader new file mode 100644 index 0000000..54c2518 --- /dev/null +++ b/.docheader @@ -0,0 +1,13 @@ +/** + * Standalone changelog domain and CLI runtime for Fast Forward PHP packages. + * + * This file is part of fast-forward/changelog project. + * + * @author Felipe Sayao Lobato Abreu + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..76ebdb1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{yml,yaml,json,md,rst}] +indent_size = 2 + +[composer.json] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e97a82e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto +/.github/ export-ignore +/docs/ export-ignore +/tests/ export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/composer-dependency-analyser.php export-ignore +/AGENTS.md export-ignore +/phpunit.xml.dist export-ignore +/README.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1447f6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.dev-tools/ +.idea/ +.phpunit.cache/ +.vscode/ +backup/ +tmp/ +vendor/ +*.cache +.DS_Store +composer.lock diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..712fe50 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS - Fast Forward Changelog + +This repository contains the standalone changelog domain and CLI runtime used +across Fast Forward PHP packages. + +## Repository Surfaces + +- CLI entrypoint: [`bin/changelog`](bin/changelog) +- Console wiring: [`src/Console/`](src/Console/) +- Commands: [`src/Console/Command/`](src/Console/Command/) +- Domain model: [`src/Document/`](src/Document/), [`src/Entry/`](src/Entry/) +- Parsing and rendering: [`src/Parser/`](src/Parser/), [`src/Renderer/`](src/Renderer/) +- File and Git helpers: [`src/Filesystem/`](src/Filesystem/), [`src/Git/`](src/Git/) +- Changelog services: [`src/Manager/`](src/Manager/) +- Tests: [`tests/`](tests/) +- Docs: [`docs/`](docs/) +- Release history: [`CHANGELOG.md`](CHANGELOG.md) + +## Setup And Local Workflow + +- Install dependencies with `composer install`. +- Run focused tests with `vendor/bin/phpunit`. +- Validate package metadata with `composer validate --strict`. + +## Design Notes + +- Keep the package free of runtime dependencies on `fast-forward/dev-tools`. +- Keep command orchestration thin and push changelog behavior into focused services. +- Preserve deterministic Keep a Changelog rendering so consumer workflows can rely on stable output. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d90da2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Bootstrap the standalone changelog domain, document model, manager, and reusable CLI commands (#1) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f320900 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Felipe Sayao Lobato Abreu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 656d21e..9dbae3c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,107 @@ -# changelog +# Fast Forward Changelog + Standalone changelog domain and CLI runtime for Fast Forward PHP packages. + +[![PHP Version](https://img.shields.io/badge/php-%5E8.3-777BB4?logo=php&logoColor=white)](https://www.php.net/releases/) +[![Composer Package](https://img.shields.io/badge/composer-fast--forward%2Fchangelog-F28D1A.svg?logo=composer&logoColor=white)](https://packagist.org/packages/fast-forward/changelog) +[![License](https://img.shields.io/github/license/php-fast-forward/changelog?color=64748B)](LICENSE) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/php-fast-forward?logo=githubsponsors&logoColor=white&color=EC4899)](https://github.com/sponsors/php-fast-forward) + +## ✨ Features + +- 📘 Parse and render Keep a Changelog 1.1.0 documents deterministically +- 🛠️ Manage changelog entries and promote `Unreleased` into published releases +- 🚀 Expose reusable Symfony Console commands for release automation +- 🔌 Stay embeddable so larger CLIs can register the commands directly + +## 📦 Installation + +```bash +composer require fast-forward/changelog +``` + +Requirements: + +- PHP `8.3+` +- Symfony Console, Filesystem, and Process components + +## 🛠️ Usage + +Run the standalone CLI: + +```bash +changelog list +changelog changelog:entry "Add release automation" +changelog changelog:resolve-version +changelog changelog:render-release-notes 1.2.0 +``` + +Register the commands inside another Symfony Console application: + +```php +use DI\Container; +use FastForward\Changelog\Console\Command\ChangelogEntryCommand; +use FastForward\Changelog\Console\Command\ChangelogPromoteCommand; +use FastForward\Changelog\Console\Command\ChangelogReleaseNotesRenderCommand; +use FastForward\Changelog\Console\Command\ChangelogVersionResolveCommand; +use Symfony\Component\Console\Application; + +$container = new Container(); +$application = new Application('My Tooling'); + +$application->add($container->get(ChangelogEntryCommand::class)); +$application->add($container->get(ChangelogPromoteCommand::class)); +$application->add($container->get(ChangelogVersionResolveCommand::class)); +$application->add($container->get(ChangelogReleaseNotesRenderCommand::class)); +``` + +## 🧰 API Summary + +| Class | Responsibility | +|-------|----------------| +| `ChangelogManager` | Load, mutate, promote, infer, and render changelog releases | +| `ChangelogParser` | Parse Markdown into the managed changelog document model | +| `MarkdownRenderer` | Render deterministic changelog Markdown and release-note bodies | +| `ChangelogDocument` / `ChangelogRelease` | Immutable document model for release sections | + +## 🔌 Integration + +This package is designed to be shared by: + +- `fast-forward/dev-tools` as an aggregator of reusable CLI domains +- `fast-forward/github-actions` as a source of changelog-aware workflow commands +- standalone package repositories that want deterministic changelog automation + +## 📁 Directory Structure + +```text +bin/ +docs/ +src/ + Console/ + Document/ + Entry/ + Filesystem/ + Git/ + Manager/ + Parser/ + Renderer/ +tests/ +``` + +## 🛡 License + +MIT © 2026 Felipe Sayao Lobato Abreu + +## 🤝 Contributing + +Issues and pull requests are welcome. Run `composer validate --strict` and `vendor/bin/phpunit` before opening a PR. + +## 🔗 Links + +- [Repository](https://github.com/php-fast-forward/changelog) +- [Issues](https://github.com/php-fast-forward/changelog/issues) +- [Packagist](https://packagist.org/packages/fast-forward/changelog) +- [Documentation](https://php-fast-forward.github.io/changelog/) +- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +- [Semantic Versioning](https://semver.org/spec/v2.0.0.html) diff --git a/bin/changelog b/bin/changelog new file mode 100755 index 0000000..9bbaa69 --- /dev/null +++ b/bin/changelog @@ -0,0 +1,37 @@ +#!/usr/bin/env php + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +use FastForward\Changelog\Console\Changelog; + +$autoloadCandidates = [ + __DIR__ . '/../vendor/autoload.php', + __DIR__ . '/../../../autoload.php', +]; + +foreach ($autoloadCandidates as $autoloadCandidate) { + if (is_file($autoloadCandidate)) { + require $autoloadCandidate; + + exit((new Changelog())->run()); + } +} + +fwrite(STDERR, "Could not locate Composer autoload.php for fast-forward/changelog.\n"); + +exit(1); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..feffed3 --- /dev/null +++ b/composer.json @@ -0,0 +1,80 @@ +{ + "name": "fast-forward/changelog", + "description": "Standalone changelog domain and CLI runtime for Fast Forward PHP packages.", + "license": "MIT", + "type": "library", + "keywords": [ + "changelog", + "cli", + "fast-forward", + "keep-a-changelog", + "php-di", + "release-notes", + "symfony-console" + ], + "readme": "README.md", + "authors": [ + { + "name": "Felipe Sayao Lobato Abreu", + "email": "github@mentordosnerds.com", + "homepage": "https://github.com/coisa", + "role": "Maintainer" + } + ], + "homepage": "https://github.com/php-fast-forward/changelog", + "support": { + "issues": "https://github.com/php-fast-forward/changelog/issues", + "wiki": "https://github.com/php-fast-forward/changelog/wiki", + "source": "https://github.com/php-fast-forward/changelog", + "docs": "https://php-fast-forward.github.io/changelog/" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/php-fast-forward" + }, + { + "type": "custom", + "url": "https://www.paypal.com/donate/?business=JLDAF45XZ8D84" + } + ], + "require": { + "php": "^8.3", + "php-di/php-di": "^7.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/filesystem": "^7.4 || ^8.0", + "symfony/process": "^7.4 || ^8.0", + "thecodingmachine/safe": "^3.4" + }, + "require-dev": { + "phpunit/phpunit": "^12.0 || ^13.0" + }, + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "FastForward\\Changelog\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "FastForward\\Changelog\\Tests\\": "tests/" + } + }, + "bin": [ + "bin/changelog" + ], + "config": { + "platform": { + "php": "8.3.0" + }, + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..33480c7 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,28 @@ +Fast Forward Changelog +====================== + +``fast-forward/changelog`` provides the standalone changelog domain and CLI +runtime used by Fast Forward PHP packages. + +Installation +------------ + +.. code-block:: bash + + composer require fast-forward/changelog + +Standalone CLI +-------------- + +.. code-block:: bash + + changelog changelog:entry "Add release automation" + changelog changelog:resolve-version + changelog changelog:render-release-notes 1.2.0 + +Embedded Commands +----------------- + +The package also exposes reusable Symfony Console commands so larger tooling +applications can register them directly inside local runtimes such as +``fast-forward/dev-tools`` and ``fast-forward/github-actions``. diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8f7e83f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + + tests + + + diff --git a/src/Console/Changelog.php b/src/Console/Changelog.php new file mode 100644 index 0000000..db4a513 --- /dev/null +++ b/src/Console/Changelog.php @@ -0,0 +1,64 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Console; + +use Composer\InstalledVersions; +use DI\Container; +use FastForward\Changelog\Console\Command\ChangelogEntryCommand; +use FastForward\Changelog\Console\Command\ChangelogPromoteCommand; +use FastForward\Changelog\Console\Command\ChangelogReleaseNotesRenderCommand; +use FastForward\Changelog\Console\Command\ChangelogVersionResolveCommand; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; + +final class Changelog extends Application +{ + /** + * @var list> + */ + private const COMMANDS = [ + ChangelogEntryCommand::class, + ChangelogPromoteCommand::class, + ChangelogVersionResolveCommand::class, + ChangelogReleaseNotesRenderCommand::class, + ]; + + public function __construct( + private readonly Container $container = new Container(), + ) { + $version = InstalledVersions::getPrettyVersion('fast-forward/changelog') ?? '0.1.x-dev'; + + parent::__construct('Fast Forward Changelog', $version); + + foreach (self::COMMANDS as $commandClassName) { + $this->add($this->resolveCommand($commandClassName)); + } + } + + /** + * @param class-string $commandClassName + */ + private function resolveCommand(string $commandClassName): Command + { + /** @var Command $command */ + $command = $this->container->get($commandClassName); + + return $command; + } +} diff --git a/src/Console/Command/ChangelogEntryCommand.php b/src/Console/Command/ChangelogEntryCommand.php new file mode 100644 index 0000000..3077e74 --- /dev/null +++ b/src/Console/Command/ChangelogEntryCommand.php @@ -0,0 +1,119 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Console\Command; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Entry\ChangelogEntryType; +use FastForward\Changelog\Filesystem\PackageFilesystem; +use FastForward\Changelog\Manager\ChangelogManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +use function Safe\getcwd; + +#[AsCommand( + name: 'changelog:entry', + description: 'Add a changelog entry to Unreleased or a specific version section.', +)] +final class ChangelogEntryCommand extends Command +{ + public function __construct( + private readonly ChangelogManager $changelogManager, + private readonly PackageFilesystem $filesystem, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addArgument( + name: 'message', + mode: InputArgument::REQUIRED, + description: 'The changelog entry text to append.', + ) + ->addOption( + name: 'type', + shortcut: 't', + mode: InputOption::VALUE_REQUIRED, + description: 'The changelog category (added, changed, deprecated, removed, fixed, security).', + default: 'added', + ) + ->addOption( + name: 'release', + mode: InputOption::VALUE_REQUIRED, + description: 'The target release section. Defaults to Unreleased.', + default: ChangelogDocument::UNRELEASED_VERSION, + ) + ->addOption( + name: 'date', + mode: InputOption::VALUE_REQUIRED, + description: 'Optional release date for published sections in YYYY-MM-DD format.', + ) + ->addOption( + name: 'file', + mode: InputOption::VALUE_REQUIRED, + description: 'Path to the changelog file.', + default: 'CHANGELOG.md', + ) + ->addOption( + name: 'working-dir', + mode: InputOption::VALUE_REQUIRED, + description: 'Working directory used to resolve relative paths.', + default: getcwd() ?: '.', + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $file = $this->filesystem->getAbsolutePath( + (string) $input->getOption('file'), + (string) $input->getOption('working-dir'), + ); + $type = ChangelogEntryType::fromInput((string) $input->getOption('type')); + $release = (string) $input->getOption('release'); + $date = $input->getOption('date'); + $message = (string) $input->getArgument('message'); + + $this->changelogManager->addEntry( + $file, + $type, + $message, + $release, + \is_string($date) ? $date : null, + ); + + $io->success(\sprintf('Added %s changelog entry to [%s] in %s.', strtolower($type->value), $release, $file)); + + return self::SUCCESS; + } +} diff --git a/src/Console/Command/ChangelogPromoteCommand.php b/src/Console/Command/ChangelogPromoteCommand.php new file mode 100644 index 0000000..9bf8ef6 --- /dev/null +++ b/src/Console/Command/ChangelogPromoteCommand.php @@ -0,0 +1,96 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Console\Command; + +use DateTimeImmutable; +use FastForward\Changelog\Filesystem\PackageFilesystem; +use FastForward\Changelog\Manager\ChangelogManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +use function Safe\getcwd; + +#[AsCommand( + name: 'changelog:promote', + description: 'Promote Unreleased entries into a published changelog version.', +)] +final class ChangelogPromoteCommand extends Command +{ + public function __construct( + private readonly ChangelogManager $changelogManager, + private readonly PackageFilesystem $filesystem, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addArgument( + name: 'version', + mode: InputArgument::REQUIRED, + description: 'The semantic version that should receive the current Unreleased entries.', + ) + ->addOption( + name: 'date', + mode: InputOption::VALUE_REQUIRED, + description: 'The release date to record in YYYY-MM-DD format.', + ) + ->addOption( + name: 'file', + mode: InputOption::VALUE_REQUIRED, + description: 'Path to the changelog file.', + default: 'CHANGELOG.md', + ) + ->addOption( + name: 'working-dir', + mode: InputOption::VALUE_REQUIRED, + description: 'Working directory used to resolve relative paths.', + default: getcwd() ?: '.', + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $file = $this->filesystem->getAbsolutePath( + (string) $input->getOption('file'), + (string) $input->getOption('working-dir'), + ); + $version = (string) $input->getArgument('version'); + $date = (string) ($input->getOption('date') ?: (new DateTimeImmutable('now'))->format('Y-m-d')); + + $this->changelogManager->promote($file, $version, $date); + $io->success(\sprintf('Promoted Unreleased changelog entries to [%s] in %s.', $version, $file)); + + return self::SUCCESS; + } +} diff --git a/src/Console/Command/ChangelogReleaseNotesRenderCommand.php b/src/Console/Command/ChangelogReleaseNotesRenderCommand.php new file mode 100644 index 0000000..a339827 --- /dev/null +++ b/src/Console/Command/ChangelogReleaseNotesRenderCommand.php @@ -0,0 +1,101 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Console\Command; + +use FastForward\Changelog\Filesystem\PackageFilesystem; +use FastForward\Changelog\Manager\ChangelogManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +use function Safe\getcwd; + +#[AsCommand( + name: 'changelog:render-release-notes', + description: 'Render a changelog section into a release-notes body or output file.', + aliases: ['changelog:show'], +)] +final class ChangelogReleaseNotesRenderCommand extends Command +{ + public function __construct( + private readonly ChangelogManager $changelogManager, + private readonly PackageFilesystem $filesystem, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addArgument( + name: 'version', + mode: InputArgument::REQUIRED, + description: 'Released changelog version to render.', + ) + ->addOption( + name: 'file', + mode: InputOption::VALUE_REQUIRED, + description: 'Path to the changelog file.', + default: 'CHANGELOG.md', + ) + ->addOption( + name: 'output-file', + mode: InputOption::VALUE_REQUIRED, + description: 'Write the rendered release notes to a file instead of STDOUT.', + ) + ->addOption( + name: 'working-dir', + mode: InputOption::VALUE_REQUIRED, + description: 'Working directory used to resolve relative paths.', + default: getcwd() ?: '.', + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $workingDirectory = (string) $input->getOption('working-dir'); + $file = $this->filesystem->getAbsolutePath((string) $input->getOption('file'), $workingDirectory); + $releaseNotes = $this->changelogManager->renderReleaseNotes($file, (string) $input->getArgument('version')); + $outputFile = trim((string) $input->getOption('output-file')); + + if ('' === $outputFile) { + $output->write($releaseNotes); + + return self::SUCCESS; + } + + $absoluteOutputFile = $this->filesystem->getAbsolutePath($outputFile, $workingDirectory); + $this->filesystem->dumpFile($absoluteOutputFile, $releaseNotes); + $io->success(\sprintf('Release notes rendered to %s.', $absoluteOutputFile)); + + return self::SUCCESS; + } +} diff --git a/src/Console/Command/ChangelogVersionResolveCommand.php b/src/Console/Command/ChangelogVersionResolveCommand.php new file mode 100644 index 0000000..a7f05b1 --- /dev/null +++ b/src/Console/Command/ChangelogVersionResolveCommand.php @@ -0,0 +1,100 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Console\Command; + +use FastForward\Changelog\Filesystem\PackageFilesystem; +use FastForward\Changelog\Manager\ChangelogManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +use function Safe\getcwd; + +#[AsCommand( + name: 'changelog:resolve-version', + description: 'Resolve the release version from input or infer it from Unreleased entries.', + aliases: ['changelog:next-version'], +)] +final class ChangelogVersionResolveCommand extends Command +{ + public function __construct( + private readonly ChangelogManager $changelogManager, + private readonly PackageFilesystem $filesystem, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addArgument( + name: 'version', + mode: InputArgument::OPTIONAL, + description: 'Explicit release version. When omitted, infer it from the changelog.', + ) + ->addOption( + name: 'file', + mode: InputOption::VALUE_REQUIRED, + description: 'Path to the changelog file.', + default: 'CHANGELOG.md', + ) + ->addOption( + name: 'current-version', + mode: InputOption::VALUE_REQUIRED, + description: 'Explicit current version used as the bump base for inference.', + ) + ->addOption( + name: 'working-dir', + mode: InputOption::VALUE_REQUIRED, + description: 'Working directory used to resolve relative paths.', + default: getcwd() ?: '.', + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $version = trim((string) $input->getArgument('version')); + + if ('' === $version) { + $file = $this->filesystem->getAbsolutePath( + (string) $input->getOption('file'), + (string) $input->getOption('working-dir'), + ); + $currentVersion = $input->getOption('current-version'); + $version = $this->changelogManager->inferNextVersion( + $file, + \is_string($currentVersion) ? $currentVersion : null, + ); + } + + $output->writeln($version); + + return self::SUCCESS; + } +} diff --git a/src/Document/ChangelogDocument.php b/src/Document/ChangelogDocument.php new file mode 100644 index 0000000..fc8352f --- /dev/null +++ b/src/Document/ChangelogDocument.php @@ -0,0 +1,187 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Document; + +use FastForward\Changelog\Entry\ChangelogEntryType; + +final readonly class ChangelogDocument +{ + public const string UNRELEASED_VERSION = 'Unreleased'; + + /** + * @param list $releases + */ + public function __construct( + private array $releases, + ) {} + + public static function create(): self + { + return new self([new ChangelogRelease(self::UNRELEASED_VERSION)]); + } + + /** + * @return list + */ + public function getReleases(): array + { + return $this->releases; + } + + public function getUnreleased(): ChangelogRelease + { + foreach ($this->releases as $release) { + if ($release->isUnreleased()) { + return $release; + } + } + + return new ChangelogRelease(self::UNRELEASED_VERSION); + } + + public function getRelease(string $version): ?ChangelogRelease + { + foreach ($this->releases as $release) { + if ($release->getVersion() === $version) { + return $release; + } + } + + return null; + } + + public function getLatestPublishedRelease(): ?ChangelogRelease + { + foreach ($this->releases as $release) { + if (! $release->isUnreleased()) { + return $release; + } + } + + return null; + } + + public function withRelease(ChangelogRelease $target): self + { + $releases = []; + $replaced = false; + + foreach ($this->releases as $release) { + if ($release->getVersion() === $target->getVersion()) { + $releases[] = $target; + $replaced = true; + + continue; + } + + $releases[] = $release; + } + + if (! $replaced) { + if ($target->isUnreleased()) { + array_unshift($releases, $target); + } else { + $inserted = false; + + foreach ($releases as $index => $release) { + if ($release->isUnreleased()) { + continue; + } + + array_splice($releases, $index, 0, [$target]); + $inserted = true; + + break; + } + + if (! $inserted) { + $releases[] = $target; + } + } + } + + return new self($this->normalizeUnreleasedPosition($releases)); + } + + public function promoteUnreleased(string $version, string $date): self + { + $unreleased = $this->getUnreleased(); + $promoted = new ChangelogRelease($version, $date, $unreleased->getEntries()); + $currentVersion = $this->getRelease($version); + + if ($currentVersion instanceof ChangelogRelease) { + $mergedEntries = $currentVersion->getEntries(); + + foreach (ChangelogEntryType::ordered() as $type) { + $mergedEntries[$type->value] = array_values(array_unique([ + ...$currentVersion->getEntriesFor($type), + ...$unreleased->getEntriesFor($type), + ])); + } + + $promoted = new ChangelogRelease($version, $date, $mergedEntries); + } + + $releases = []; + + foreach ($this->releases as $release) { + if ($release->isUnreleased()) { + $releases[] = new ChangelogRelease(self::UNRELEASED_VERSION); + $releases[] = $promoted; + + continue; + } + + if ($release->getVersion() === $version) { + continue; + } + + $releases[] = $release; + } + + if ([] === $releases) { + $releases = [new ChangelogRelease(self::UNRELEASED_VERSION), $promoted]; + } + + return new self($this->normalizeUnreleasedPosition($releases)); + } + + /** + * @param list $releases + * + * @return list + */ + private function normalizeUnreleasedPosition(array $releases): array + { + $unreleased = null; + $published = []; + + foreach ($releases as $release) { + if ($release->isUnreleased()) { + $unreleased ??= $release; + + continue; + } + + $published[] = $release; + } + + return [$unreleased ?? new ChangelogRelease(self::UNRELEASED_VERSION), ...$published]; + } +} diff --git a/src/Document/ChangelogRelease.php b/src/Document/ChangelogRelease.php new file mode 100644 index 0000000..fa39918 --- /dev/null +++ b/src/Document/ChangelogRelease.php @@ -0,0 +1,112 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Document; + +use FastForward\Changelog\Entry\ChangelogEntryType; + +final class ChangelogRelease +{ + /** + * @var array> + */ + private array $entries = []; + + /** + * @param array> $entries + */ + public function __construct( + private readonly string $version, + private readonly ?string $date = null, + array $entries = [], + ) { + foreach (ChangelogEntryType::ordered() as $type) { + $this->entries[$type->value] = array_values(array_unique($entries[$type->value] ?? [])); + } + } + + public function getVersion(): string + { + return $this->version; + } + + public function getDate(): ?string + { + return $this->date; + } + + public function isUnreleased(): bool + { + return ChangelogDocument::UNRELEASED_VERSION === $this->version; + } + + /** + * @return array> + */ + public function getEntries(): array + { + return $this->entries; + } + + /** + * @return list + */ + public function getEntriesFor(ChangelogEntryType $type): array + { + return $this->entries[$type->value]; + } + + public function hasEntries(): bool + { + foreach ($this->entries as $entries) { + if ([] !== $entries) { + return true; + } + } + + return false; + } + + public function withEntry(ChangelogEntryType $type, string $entry): self + { + $entries = $this->entries; + $entry = trim($entry); + + if ('' === $entry) { + return $this; + } + + $entries[$type->value][] = $entry; + $entries[$type->value] = array_values(array_unique($entries[$type->value])); + + return new self($this->version, $this->date, $entries); + } + + /** + * @param array> $entries + */ + public function withEntries(array $entries): self + { + return new self($this->version, $this->date, $entries); + } + + public function withDate(?string $date): self + { + return new self($this->version, $date, $this->entries); + } +} diff --git a/src/Entry/ChangelogEntryType.php b/src/Entry/ChangelogEntryType.php new file mode 100644 index 0000000..0ab9eaa --- /dev/null +++ b/src/Entry/ChangelogEntryType.php @@ -0,0 +1,47 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Entry; + +use InvalidArgumentException; + +enum ChangelogEntryType: string +{ + case Added = 'Added'; + case Changed = 'Changed'; + case Deprecated = 'Deprecated'; + case Removed = 'Removed'; + case Fixed = 'Fixed'; + case Security = 'Security'; + + /** + * @return list + */ + public static function ordered(): array + { + return [self::Added, self::Changed, self::Deprecated, self::Removed, self::Fixed, self::Security]; + } + + public static function fromInput(string $value): self + { + $normalized = ucfirst(strtolower(trim($value))); + + return self::tryFrom($normalized) + ?? throw new InvalidArgumentException(\sprintf('Unsupported changelog type "%s".', $value)); + } +} diff --git a/src/Filesystem/PackageFilesystem.php b/src/Filesystem/PackageFilesystem.php new file mode 100644 index 0000000..6b1f32f --- /dev/null +++ b/src/Filesystem/PackageFilesystem.php @@ -0,0 +1,68 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Filesystem; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +use function Safe\file_get_contents; +use function Safe\getcwd; + +final readonly class PackageFilesystem +{ + public function __construct( + private Filesystem $filesystem, + ) {} + + public function exists(string $file, ?string $basePath = null): bool + { + return $this->filesystem->exists($this->getAbsolutePath($file, $basePath)); + } + + public function readFile(string $file, ?string $basePath = null): string + { + return file_get_contents($this->getAbsolutePath($file, $basePath)); + } + + public function dumpFile(string $file, string $contents, ?string $basePath = null): void + { + $this->filesystem->dumpFile($this->getAbsolutePath($file, $basePath), $contents); + } + + public function mkdir(string $directory, int $mode = 0o777, ?string $basePath = null): void + { + $this->filesystem->mkdir($this->getAbsolutePath($directory, $basePath), $mode); + } + + public function getAbsolutePath(string $file, ?string $basePath = null): string + { + $basePath ??= getcwd(); + + if (! Path::isAbsolute($basePath)) { + $basePath = Path::makeAbsolute($basePath, getcwd()); + } + + return Path::makeAbsolute($file, $basePath); + } + + public function getDirectory(string $path, int $levels = 1): string + { + return \dirname($path, $levels); + } +} diff --git a/src/Git/GitRepositoryUrlResolver.php b/src/Git/GitRepositoryUrlResolver.php new file mode 100644 index 0000000..29b29a4 --- /dev/null +++ b/src/Git/GitRepositoryUrlResolver.php @@ -0,0 +1,48 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Git; + +use FastForward\Changelog\Filesystem\PackageFilesystem; +use Symfony\Component\Process\Process; + +final readonly class GitRepositoryUrlResolver +{ + public function __construct( + private PackageFilesystem $filesystem, + ) {} + + public function resolve(?string $workingDirectory): ?string + { + if (null === $workingDirectory || '' === trim($workingDirectory)) { + return null; + } + + $process = new Process(['git', 'config', '--get', 'remote.origin.url']); + $process->setWorkingDirectory($this->filesystem->getAbsolutePath($workingDirectory)); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $repositoryUrl = trim($process->getOutput()); + + return '' === $repositoryUrl ? null : $repositoryUrl; + } +} diff --git a/src/Manager/ChangelogManager.php b/src/Manager/ChangelogManager.php new file mode 100644 index 0000000..97e5919 --- /dev/null +++ b/src/Manager/ChangelogManager.php @@ -0,0 +1,124 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Manager; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Document\ChangelogRelease; +use FastForward\Changelog\Entry\ChangelogEntryType; +use FastForward\Changelog\Filesystem\PackageFilesystem; +use FastForward\Changelog\Git\GitRepositoryUrlResolver; +use FastForward\Changelog\Parser\ChangelogParser; +use FastForward\Changelog\Renderer\MarkdownRenderer; +use RuntimeException; + +final readonly class ChangelogManager +{ + public function __construct( + private PackageFilesystem $filesystem, + private ChangelogParser $parser, + private MarkdownRenderer $renderer, + private GitRepositoryUrlResolver $gitRepositoryUrlResolver, + ) {} + + public function addEntry( + string $file, + ChangelogEntryType $type, + string $message, + string $version = ChangelogDocument::UNRELEASED_VERSION, + ?string $date = null, + ): void { + $document = $this->load($file); + $release = $document->getRelease($version) ?? new ChangelogRelease($version, $date); + + if (null !== $date && $release->getDate() !== $date) { + $release = $release->withDate($date); + } + + $this->persist($file, $document->withRelease($release->withEntry($type, $message))); + } + + public function promote(string $file, string $version, string $date): void + { + $document = $this->load($file); + + if (! $document->getUnreleased()->hasEntries()) { + throw new RuntimeException(\sprintf('%s does not contain unreleased entries to promote.', $file)); + } + + $this->persist($file, $document->promoteUnreleased($version, $date)); + } + + public function inferNextVersion(string $file, ?string $currentVersion = null): string + { + $document = $this->load($file); + $unreleased = $document->getUnreleased(); + + if (! $unreleased->hasEntries()) { + throw new RuntimeException(\sprintf('%s does not contain unreleased entries to infer a version from.', $file)); + } + + $currentVersion ??= $document->getLatestPublishedRelease()?->getVersion() ?? '0.0.0'; + [$major, $minor, $patch] = array_map(intval(...), explode('.', $currentVersion)); + + if ([] !== $unreleased->getEntriesFor(ChangelogEntryType::Removed) + || [] !== $unreleased->getEntriesFor(ChangelogEntryType::Deprecated) + ) { + return \sprintf('%d.0.0', $major + 1); + } + + if ([] !== $unreleased->getEntriesFor(ChangelogEntryType::Added) + || [] !== $unreleased->getEntriesFor(ChangelogEntryType::Changed) + ) { + return \sprintf('%d.%d.0', $major, $minor + 1); + } + + return \sprintf('%d.%d.%d', $major, $minor, $patch + 1); + } + + public function renderReleaseNotes(string $file, string $version): string + { + $release = $this->load($file)->getRelease($version); + + if (! $release instanceof ChangelogRelease) { + throw new RuntimeException(\sprintf('%s does not contain a [%s] section.', $file, $version)); + } + + return $this->renderer->renderReleaseBody($release); + } + + public function load(string $file): ChangelogDocument + { + if (! $this->filesystem->exists($file)) { + return ChangelogDocument::create(); + } + + return $this->parser->parse($this->filesystem->readFile($file)); + } + + private function persist(string $file, ChangelogDocument $document): void + { + $this->filesystem->dumpFile( + $file, + $this->renderer->render( + $document, + $this->gitRepositoryUrlResolver->resolve($this->filesystem->getDirectory($file)), + ), + ); + } +} diff --git a/src/Parser/ChangelogParser.php b/src/Parser/ChangelogParser.php new file mode 100644 index 0000000..57cca68 --- /dev/null +++ b/src/Parser/ChangelogParser.php @@ -0,0 +1,109 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Parser; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Document\ChangelogRelease; +use FastForward\Changelog\Entry\ChangelogEntryType; + +use function Safe\preg_match; +use function Safe\preg_match_all; +use function Safe\preg_split; +use function array_values; +use function preg_quote; +use function trim; + +final class ChangelogParser +{ + public function parse(string $contents): ChangelogDocument + { + if ('' === trim($contents)) { + return ChangelogDocument::create(); + } + + preg_match_all( + '/^## \[(?[^\]]+)\](?: - (?\d{4}-\d{2}-\d{2}))?$/m', + $contents, + $matches, + \PREG_OFFSET_CAPTURE, + ); + + if ([] === $matches[0]) { + return ChangelogDocument::create(); + } + + $releases = []; + $sectionCount = \count($matches[0]); + + for ($index = 0; $index < $sectionCount; ++$index) { + $heading = $matches[0][$index][0]; + $offset = $matches[0][$index][1]; + $bodyStart = $offset + \strlen((string) $heading); + $bodyEnd = $matches[0][$index + 1][1] ?? \strlen($contents); + $body = trim(substr($contents, $bodyStart, $bodyEnd - $bodyStart)); + + $entries = []; + + foreach (ChangelogEntryType::ordered() as $type) { + $entries[$type->value] = $this->extractEntries($body, $type); + } + + $releases[] = new ChangelogRelease( + $matches['version'][$index][0], + '' === ($matches['date'][$index][0] ?? '') ? null : $matches['date'][$index][0], + $entries, + ); + } + + return new ChangelogDocument($releases); + } + + /** + * @return list + */ + private function extractEntries(string $body, ChangelogEntryType $type): array + { + $pattern = \sprintf('/^### %s\s*(?:\R(?.*?))?(?=^### |\z)/ms', preg_quote($type->value, '/')); + + if (1 !== preg_match($pattern, $body, $matches)) { + return []; + } + + $lines = preg_split('/\R/', trim($matches['body'] ?? '')); + $entries = []; + + foreach ($lines as $line) { + $line = trim((string) $line); + + if (! str_starts_with($line, '- ')) { + continue; + } + + $entry = trim(substr($line, 2)); + + if ('' === $entry) { + continue; + } + + $entries[] = $entry; + } + + return array_values(array_unique($entries)); + } +} diff --git a/src/Renderer/MarkdownRenderer.php b/src/Renderer/MarkdownRenderer.php new file mode 100644 index 0000000..f73fc9e --- /dev/null +++ b/src/Renderer/MarkdownRenderer.php @@ -0,0 +1,184 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Renderer; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Document\ChangelogRelease; +use FastForward\Changelog\Entry\ChangelogEntryType; + +use function Safe\preg_match; +use function explode; +use function implode; +use function rtrim; +use function str_ends_with; +use function substr; +use function trim; + +final readonly class MarkdownRenderer +{ + private const string INTRODUCTION = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)."; + + public function render(ChangelogDocument $document, ?string $repositoryUrl = null): string + { + $lines = explode("\n", self::INTRODUCTION); + + foreach ($document->getReleases() as $release) { + if ('' !== $lines[array_key_last($lines)]) { + $lines[] = ''; + } + + $lines = [...$lines, ...$this->renderRelease($release)]; + } + + $references = $this->renderReferences($document, $repositoryUrl); + + if ([] !== $references) { + if ('' !== $lines[array_key_last($lines)]) { + $lines[] = ''; + } + + $lines = [...$lines, ...$references]; + } + + return implode("\n", $lines) . "\n"; + } + + public function renderReleaseBody(ChangelogRelease $release): string + { + return implode("\n", \array_slice($this->renderRelease($release), 2)) . "\n"; + } + + /** + * @return list + */ + private function renderRelease(ChangelogRelease $release): array + { + $heading = $release->isUnreleased() + ? \sprintf('## [%s]', ChangelogDocument::UNRELEASED_VERSION) + : (null === $release->getDate() + ? \sprintf('## [%s]', $release->getVersion()) + : \sprintf('## [%s] - %s', $release->getVersion(), $release->getDate())); + + $lines = [$heading, '']; + $renderedSections = 0; + + foreach (ChangelogEntryType::ordered() as $type) { + $sectionEntries = $release->getEntriesFor($type); + + if ([] === $sectionEntries) { + continue; + } + + if (0 < $renderedSections) { + $lines[] = ''; + } + + $lines[] = '### ' . $type->value; + $lines[] = ''; + + foreach ($sectionEntries as $entry) { + $lines[] = '- ' . $entry; + } + + ++$renderedSections; + } + + return $lines; + } + + /** + * @return list + */ + private function renderReferences(ChangelogDocument $document, ?string $repositoryUrl): array + { + $normalizedRepositoryUrl = $this->normalizeRepositoryUrl($repositoryUrl); + + if (null === $normalizedRepositoryUrl) { + return []; + } + + $published = array_values(array_filter( + $document->getReleases(), + static fn(ChangelogRelease $release): bool => ! $release->isUnreleased(), + )); + + if ([] === $published) { + return []; + } + + $references = [ + \sprintf( + '[unreleased]: %s/compare/%s...HEAD', + $normalizedRepositoryUrl, + $this->resolveTag($published[0]), + ), + ]; + + foreach ($published as $index => $release) { + $references[] = isset($published[$index + 1]) + ? \sprintf( + '[%s]: %s/compare/%s...%s', + $release->getVersion(), + $normalizedRepositoryUrl, + $this->resolveTag($published[$index + 1]), + $this->resolveTag($release), + ) + : \sprintf( + '[%s]: %s/releases/tag/%s', + $release->getVersion(), + $normalizedRepositoryUrl, + $this->resolveTag($release), + ); + } + + return ['', ...$references]; + } + + private function resolveTag(ChangelogRelease $release): string + { + return 'v' . $release->getVersion(); + } + + private function normalizeRepositoryUrl(?string $repositoryUrl): ?string + { + if (null === $repositoryUrl) { + return null; + } + + $repositoryUrl = trim($repositoryUrl); + + if ('' === $repositoryUrl) { + return null; + } + + if (1 === preg_match('~^git@(?[^:]+):(?.+)$~', $repositoryUrl, $matches)) { + $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path']; + } + + if (1 === preg_match('~^ssh://git@(?[^/]+)/(?.+)$~', $repositoryUrl, $matches)) { + $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path']; + } + + if (str_ends_with($repositoryUrl, '.git')) { + $repositoryUrl = substr($repositoryUrl, 0, -4); + } + + return rtrim($repositoryUrl, '/'); + } +} diff --git a/tests/Console/ChangelogTest.php b/tests/Console/ChangelogTest.php new file mode 100644 index 0000000..cded7ba --- /dev/null +++ b/tests/Console/ChangelogTest.php @@ -0,0 +1,42 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Tests\Console; + +use DI\Container; +use FastForward\Changelog\Console\Changelog; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Changelog::class)] +final class ChangelogTest extends TestCase +{ + #[Test] + public function applicationWillRegisterTheStandaloneChangelogCommands(): void + { + $application = new Changelog(new Container()); + + self::assertTrue($application->has('changelog:entry')); + self::assertTrue($application->has('changelog:promote')); + self::assertTrue($application->has('changelog:resolve-version')); + self::assertTrue($application->has('changelog:render-release-notes')); + self::assertTrue($application->has('changelog:next-version')); + self::assertTrue($application->has('changelog:show')); + } +} diff --git a/tests/Document/ChangelogDocumentTest.php b/tests/Document/ChangelogDocumentTest.php new file mode 100644 index 0000000..dc9b949 --- /dev/null +++ b/tests/Document/ChangelogDocumentTest.php @@ -0,0 +1,115 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Tests\Document; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Document\ChangelogRelease; +use FastForward\Changelog\Entry\ChangelogEntryType; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(ChangelogDocument::class)] +#[CoversClass(ChangelogRelease::class)] +#[UsesClass(ChangelogEntryType::class)] +final class ChangelogDocumentTest extends TestCase +{ + #[Test] + public function withReleaseWillKeepTheUnreleasedSectionAtTheTop(): void + { + $document = ChangelogDocument::create() + ->withRelease((new ChangelogRelease('1.1.0', '2026-04-10'))->withEntry( + ChangelogEntryType::Added, + 'Ship changelog automation', + )) + ->withRelease((new ChangelogRelease('1.0.0', '2026-04-01'))->withEntry( + ChangelogEntryType::Fixed, + 'Stabilize command output', + )); + + self::assertSame( + [ChangelogDocument::UNRELEASED_VERSION, '1.0.0', '1.1.0'], + array_map( + static fn(ChangelogRelease $release): string => $release->getVersion(), + $document->getReleases(), + ), + ); + } + + #[Test] + public function promoteUnreleasedWillMergeWithAnExistingPublishedVersion(): void + { + $document = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withEntry(ChangelogEntryType::Added, 'Add release command') + ->withEntry(ChangelogEntryType::Fixed, 'Preserve release sections'), + (new ChangelogRelease('1.2.0', '2026-04-01')) + ->withEntry(ChangelogEntryType::Added, 'Existing release note'), + ]); + + $promoted = $document->promoteUnreleased('1.2.0', '2026-04-19'); + $release = $promoted->getRelease('1.2.0'); + + self::assertInstanceOf(ChangelogRelease::class, $release); + self::assertSame('2026-04-19', $release->getDate()); + self::assertSame( + ['Existing release note', 'Add release command'], + $release->getEntriesFor(ChangelogEntryType::Added), + ); + self::assertSame(['Preserve release sections'], $release->getEntriesFor(ChangelogEntryType::Fixed)); + self::assertFalse($promoted->getUnreleased()->hasEntries()); + } + + #[Test] + public function documentAccessorsWillResolveExpectedReleaseVariants(): void + { + $document = new ChangelogDocument([new ChangelogRelease('1.2.0', '2026-04-19')]); + + self::assertSame(ChangelogDocument::UNRELEASED_VERSION, $document->getUnreleased()->getVersion()); + self::assertNull($document->getRelease('9.9.9')); + self::assertSame('1.2.0', $document->getLatestPublishedRelease()?->getVersion()); + } + + #[Test] + public function getLatestPublishedReleaseWillReturnNullWhenOnlyUnreleasedExists(): void + { + self::assertNull(ChangelogDocument::create()->getLatestPublishedRelease()); + } + + #[Test] + public function withReleaseWillReplaceExistingVersionAndInsertUnreleasedAtTheTop(): void + { + $existing = new ChangelogRelease('1.2.0', '2026-04-01'); + $replacement = (new ChangelogRelease('1.2.0', '2026-04-19')) + ->withEntry(ChangelogEntryType::Added, 'Updated note'); + $document = (new ChangelogDocument([$existing])) + ->withRelease(new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withRelease($replacement); + + self::assertSame( + [ChangelogDocument::UNRELEASED_VERSION, '1.2.0'], + array_map( + static fn(ChangelogRelease $release): string => $release->getVersion(), + $document->getReleases(), + ), + ); + self::assertSame(['Updated note'], $document->getRelease('1.2.0')?->getEntriesFor(ChangelogEntryType::Added)); + } +} diff --git a/tests/Entry/ChangelogEntryTypeTest.php b/tests/Entry/ChangelogEntryTypeTest.php new file mode 100644 index 0000000..4606e58 --- /dev/null +++ b/tests/Entry/ChangelogEntryTypeTest.php @@ -0,0 +1,61 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Tests\Entry; + +use FastForward\Changelog\Entry\ChangelogEntryType; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(ChangelogEntryType::class)] +final class ChangelogEntryTypeTest extends TestCase +{ + #[Test] + public function orderedWillReturnKeepAChangelogSectionOrder(): void + { + self::assertSame( + [ + ChangelogEntryType::Added, + ChangelogEntryType::Changed, + ChangelogEntryType::Deprecated, + ChangelogEntryType::Removed, + ChangelogEntryType::Fixed, + ChangelogEntryType::Security, + ], + ChangelogEntryType::ordered(), + ); + } + + #[Test] + public function fromInputWillNormalizeSupportedValues(): void + { + self::assertSame(ChangelogEntryType::Fixed, ChangelogEntryType::fromInput(' fixed ')); + self::assertSame(ChangelogEntryType::Security, ChangelogEntryType::fromInput('security')); + } + + #[Test] + public function fromInputWillRejectUnsupportedValues(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported changelog type "unknown".'); + + ChangelogEntryType::fromInput('unknown'); + } +} diff --git a/tests/Manager/ChangelogManagerTest.php b/tests/Manager/ChangelogManagerTest.php new file mode 100644 index 0000000..7b6e8ea --- /dev/null +++ b/tests/Manager/ChangelogManagerTest.php @@ -0,0 +1,138 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Tests\Manager; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Entry\ChangelogEntryType; +use FastForward\Changelog\Filesystem\PackageFilesystem; +use FastForward\Changelog\Git\GitRepositoryUrlResolver; +use FastForward\Changelog\Manager\ChangelogManager; +use FastForward\Changelog\Parser\ChangelogParser; +use FastForward\Changelog\Renderer\MarkdownRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Symfony\Component\Filesystem\Filesystem; + +use function Safe\mkdir; + +#[CoversClass(ChangelogManager::class)] +final class ChangelogManagerTest extends TestCase +{ + private string $temporaryDirectory; + + private string $changelogFile; + + private ChangelogManager $changelogManager; + + protected function setUp(): void + { + $this->temporaryDirectory = sys_get_temp_dir() . '/fast-forward-changelog-tests-' . uniqid(); + mkdir($this->temporaryDirectory, 0o777, true); + + $filesystem = new PackageFilesystem(new Filesystem()); + $this->changelogFile = $this->temporaryDirectory . '/CHANGELOG.md'; + $this->changelogManager = new ChangelogManager( + $filesystem, + new ChangelogParser(), + new MarkdownRenderer(), + new GitRepositoryUrlResolver($filesystem), + ); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->temporaryDirectory); + } + + #[Test] + public function addEntryWillCreateTheManagedChangelogWhenNeeded(): void + { + $this->changelogManager->addEntry( + $this->changelogFile, + ChangelogEntryType::Added, + 'Ship changelog automation', + ); + + $contents = file_get_contents($this->changelogFile); + + self::assertIsString($contents); + self::assertStringContainsString('## [Unreleased]', $contents); + self::assertStringContainsString('- Ship changelog automation', $contents); + } + + #[Test] + public function promoteWillPersistThePublishedReleaseWhenUnreleasedEntriesExist(): void + { + $this->changelogManager->addEntry( + $this->changelogFile, + ChangelogEntryType::Added, + 'Prepare release automation', + ); + + $this->changelogManager->promote($this->changelogFile, '1.0.0', '2026-04-29'); + $document = $this->changelogManager->load($this->changelogFile); + + self::assertFalse($document->getUnreleased()->hasEntries()); + self::assertSame('2026-04-29', $document->getRelease('1.0.0')?->getDate()); + self::assertSame( + ['Prepare release automation'], + $document->getRelease('1.0.0')?->getEntriesFor(ChangelogEntryType::Added), + ); + } + + #[Test] + public function inferNextVersionWillUseTheCurrentPublishedVersionAsTheBumpBase(): void + { + $this->changelogManager->addEntry($this->changelogFile, ChangelogEntryType::Added, 'Initial release'); + $this->changelogManager->promote($this->changelogFile, '1.0.0', '2026-04-29'); + $this->changelogManager->addEntry($this->changelogFile, ChangelogEntryType::Changed, 'Expand release notes'); + + self::assertSame('1.1.0', $this->changelogManager->inferNextVersion($this->changelogFile)); + } + + #[Test] + public function inferNextVersionWillThrowWhenNoUnreleasedEntriesExist(): void + { + $filesystem = new PackageFilesystem(new Filesystem()); + $filesystem->dumpFile( + $this->changelogFile, + (new MarkdownRenderer())->render(ChangelogDocument::create()), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($this->changelogFile . ' does not contain unreleased entries to infer a version from.'); + + $this->changelogManager->inferNextVersion($this->changelogFile); + } + + #[Test] + public function renderReleaseNotesWillReturnTheRenderedBodyForAPublishedRelease(): void + { + $this->changelogManager->addEntry($this->changelogFile, ChangelogEntryType::Added, 'Ship release notes'); + $this->changelogManager->promote($this->changelogFile, '1.0.0', '2026-04-29'); + + $releaseNotes = $this->changelogManager->renderReleaseNotes($this->changelogFile, '1.0.0'); + + self::assertStringContainsString('### Added', $releaseNotes); + self::assertStringContainsString('- Ship release notes', $releaseNotes); + self::assertStringNotContainsString('## [1.0.0]', $releaseNotes); + } +} diff --git a/tests/Parser/ChangelogParserTest.php b/tests/Parser/ChangelogParserTest.php new file mode 100644 index 0000000..e6a18ef --- /dev/null +++ b/tests/Parser/ChangelogParserTest.php @@ -0,0 +1,116 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Tests\Parser; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Document\ChangelogRelease; +use FastForward\Changelog\Entry\ChangelogEntryType; +use FastForward\Changelog\Parser\ChangelogParser; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +#[CoversClass(ChangelogParser::class)] +#[UsesClass(ChangelogDocument::class)] +#[UsesClass(ChangelogRelease::class)] +#[UsesClass(ChangelogEntryType::class)] +final class ChangelogParserTest extends TestCase +{ + #[Test] + public function parseWillExtractReleaseSectionsAndEntries(): void + { + $document = (new ChangelogParser())->parse(<<<'MD' + # Changelog + + All notable changes to this project will be documented in this file. + + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + ## [Unreleased] + + ### Added + + - Add release preparation workflow + + ### Fixed + + - Correct changelog checks + + ## [1.0.0] - 2026-04-01 + + ### Added + + - Initial release + MD); + + self::assertSame(ChangelogDocument::UNRELEASED_VERSION, $document->getUnreleased()->getVersion()); + self::assertSame(['Add release preparation workflow'], $document->getUnreleased()->getEntries()['Added']); + self::assertSame('2026-04-01', $document->getRelease('1.0.0')?->getDate()); + } + + #[Test] + public function parseWillReturnDefaultDocumentForEmptyContents(): void + { + $document = (new ChangelogParser())->parse(" \n\n"); + + self::assertSame([ChangelogDocument::UNRELEASED_VERSION], array_map( + static fn(ChangelogRelease $release): string => $release->getVersion(), + $document->getReleases(), + )); + } + + #[Test] + public function parseWillIgnoreUnsupportedLinesAndDeduplicateEntriesWithinASection(): void + { + $document = (new ChangelogParser())->parse(<<<'MD' + ## [Unreleased] + + ### Added + + Intro line that should be ignored + - Add sync command + - Add sync command + * + + ### Fixed + + - Repair coverage report + - + MD); + + self::assertSame(['Add sync command'], $document->getUnreleased()->getEntriesFor(ChangelogEntryType::Added)); + self::assertSame(['Repair coverage report'], $document->getUnreleased()->getEntriesFor(ChangelogEntryType::Fixed)); + self::assertSame([], $document->getUnreleased()->getEntriesFor(ChangelogEntryType::Security)); + } + + #[Test] + public function extractEntriesWillReturnEmptyArrayWhenCategoryHeadingIsMissing(): void + { + $parser = new ChangelogParser(); + $reflectionMethod = new ReflectionMethod($parser, 'extractEntries'); + + self::assertSame( + [], + $reflectionMethod->invoke($parser, "### Fixed\n\n- Repair release notes", ChangelogEntryType::Added), + ); + } +} diff --git a/tests/Renderer/MarkdownRendererTest.php b/tests/Renderer/MarkdownRendererTest.php new file mode 100644 index 0000000..4a51dcc --- /dev/null +++ b/tests/Renderer/MarkdownRendererTest.php @@ -0,0 +1,108 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/changelog + * @see https://github.com/php-fast-forward/changelog/issues + * @see https://php-fast-forward.github.io/changelog/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\Changelog\Tests\Renderer; + +use FastForward\Changelog\Document\ChangelogDocument; +use FastForward\Changelog\Document\ChangelogRelease; +use FastForward\Changelog\Entry\ChangelogEntryType; +use FastForward\Changelog\Renderer\MarkdownRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(MarkdownRenderer::class)] +#[UsesClass(ChangelogDocument::class)] +#[UsesClass(ChangelogRelease::class)] +#[UsesClass(ChangelogEntryType::class)] +final class MarkdownRendererTest extends TestCase +{ + #[Test] + public function renderWillGenerateChangelogWithHeaderAndUnreleasedSection(): void + { + $output = (new MarkdownRenderer())->render(ChangelogDocument::create()); + + self::assertStringStartsWith('# Changelog', $output); + self::assertStringContainsString('## [' . ChangelogDocument::UNRELEASED_VERSION . ']', $output); + self::assertStringNotContainsString("## [Unreleased]\n\n\n", $output); + self::assertStringEndsWith("\n", $output); + } + + #[Test] + public function renderWillIncludePublishedSectionsAndReferences(): void + { + $document = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION))->withEntry( + ChangelogEntryType::Changed, + 'Pending change' + ), + (new ChangelogRelease('1.1.0', '2026-04-02'))->withEntry(ChangelogEntryType::Changed, 'Feature B'), + (new ChangelogRelease('1.0.0', '2026-04-01'))->withEntry(ChangelogEntryType::Added, 'Feature A'), + ]); + + $output = (new MarkdownRenderer())->render($document, 'git@github.com:php-fast-forward/changelog.git'); + + self::assertStringContainsString('## [1.1.0] - 2026-04-02', $output); + self::assertStringContainsString('### Added', $output); + self::assertStringContainsString('### Changed', $output); + self::assertStringContainsString( + '[unreleased]: https://github.com/php-fast-forward/changelog/compare/v1.1.0...HEAD', + $output, + ); + self::assertStringContainsString( + '[1.1.0]: https://github.com/php-fast-forward/changelog/compare/v1.0.0...v1.1.0', + $output, + ); + } + + #[Test] + public function renderReleaseBodyWillOmitTheReleaseHeading(): void + { + $release = (new ChangelogRelease('1.2.0', '2026-04-19')) + ->withEntry(ChangelogEntryType::Added, 'Ship changelog automation'); + + $output = (new MarkdownRenderer())->renderReleaseBody($release); + + self::assertStringNotContainsString('## [1.2.0]', $output); + self::assertStringContainsString('### Added', $output); + self::assertStringContainsString('- Ship changelog automation', $output); + } + + #[Test] + public function renderWillNormalizeSshRepositoryUrlsAndTrimTrailingGitSuffix(): void + { + $document = new ChangelogDocument([ + (new ChangelogRelease('1.2.0', '2026-04-19'))->withEntry( + ChangelogEntryType::Added, + 'Ship changelog automation', + ), + ]); + + $output = (new MarkdownRenderer())->render($document, 'ssh://git@github.com/php-fast-forward/changelog.git'); + + self::assertStringContainsString( + '[unreleased]: https://github.com/php-fast-forward/changelog/compare/v1.2.0...HEAD', + $output, + ); + self::assertStringContainsString( + '[1.2.0]: https://github.com/php-fast-forward/changelog/releases/tag/v1.2.0', + $output, + ); + } +} From 3064c5c4153adb18a64b78a1c733ada6fff486be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 01:03:18 -0300 Subject: [PATCH 2/2] Fix published release insertion ordering --- src/Document/ChangelogDocument.php | 27 +++++++++++++++++++++--- tests/Document/ChangelogDocumentTest.php | 19 ++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/Document/ChangelogDocument.php b/src/Document/ChangelogDocument.php index fc8352f..285d81a 100644 --- a/src/Document/ChangelogDocument.php +++ b/src/Document/ChangelogDocument.php @@ -104,10 +104,12 @@ public function withRelease(ChangelogRelease $target): self continue; } - array_splice($releases, $index, 0, [$target]); - $inserted = true; + if ($this->shouldInsertBeforePublishedRelease($target, $release)) { + array_splice($releases, $index, 0, [$target]); + $inserted = true; - break; + break; + } } if (! $inserted) { @@ -184,4 +186,23 @@ private function normalizeUnreleasedPosition(array $releases): array return [$unreleased ?? new ChangelogRelease(self::UNRELEASED_VERSION), ...$published]; } + + private function shouldInsertBeforePublishedRelease(ChangelogRelease $target, ChangelogRelease $release): bool + { + $targetVersion = ltrim($target->getVersion(), 'vV'); + $releaseVersion = ltrim($release->getVersion(), 'vV'); + + if ( + 1 === preg_match('/^\d+(?:\.\d+)*(?:[-+][A-Za-z0-9\-.]+)?$/', $targetVersion) + && 1 === preg_match('/^\d+(?:\.\d+)*(?:[-+][A-Za-z0-9\-.]+)?$/', $releaseVersion) + ) { + return version_compare($targetVersion, $releaseVersion, '>'); + } + + if (null !== $target->getDate() && null !== $release->getDate()) { + return $target->getDate() > $release->getDate(); + } + + return false; + } } diff --git a/tests/Document/ChangelogDocumentTest.php b/tests/Document/ChangelogDocumentTest.php index dc9b949..1e31670 100644 --- a/tests/Document/ChangelogDocumentTest.php +++ b/tests/Document/ChangelogDocumentTest.php @@ -45,7 +45,7 @@ public function withReleaseWillKeepTheUnreleasedSectionAtTheTop(): void )); self::assertSame( - [ChangelogDocument::UNRELEASED_VERSION, '1.0.0', '1.1.0'], + [ChangelogDocument::UNRELEASED_VERSION, '1.1.0', '1.0.0'], array_map( static fn(ChangelogRelease $release): string => $release->getVersion(), $document->getReleases(), @@ -112,4 +112,21 @@ public function withReleaseWillReplaceExistingVersionAndInsertUnreleasedAtTheTop ); self::assertSame(['Updated note'], $document->getRelease('1.2.0')?->getEntriesFor(ChangelogEntryType::Added)); } + + #[Test] + public function withReleaseWillPreservePublishedOrderingWhenBackfillingOlderVersions(): void + { + $document = ChangelogDocument::create() + ->withRelease(new ChangelogRelease('2.0.0', '2026-04-20')) + ->withRelease(new ChangelogRelease('1.5.0', '2026-03-10')); + + self::assertSame( + [ChangelogDocument::UNRELEASED_VERSION, '2.0.0', '1.5.0'], + array_map( + static fn(ChangelogRelease $release): string => $release->getVersion(), + $document->getReleases(), + ), + ); + self::assertSame('2.0.0', $document->getLatestPublishedRelease()?->getVersion()); + } }