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..285d81a --- /dev/null +++ b/src/Document/ChangelogDocument.php @@ -0,0 +1,208 @@ + + * @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; + } + + if ($this->shouldInsertBeforePublishedRelease($target, $release)) { + 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]; + } + + 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/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..1e31670 --- /dev/null +++ b/tests/Document/ChangelogDocumentTest.php @@ -0,0 +1,132 @@ + + * @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.1.0', '1.0.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)); + } + + #[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()); + } +} 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, + ); + } +}