From 3cd8d3ed959ec9d31ecb954486e6bb999635b7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 21:02:52 -0300 Subject: [PATCH 01/11] [changelog] Add Keep a Changelog automation (#28) --- .github/workflows/changelog-bump.yml | 56 ++++ .github/workflows/release.yml | 96 +++++++ .github/workflows/require-changelog.yml | 58 ++++ .keep-a-changelog.ini | 7 + CHANGELOG.md | 264 ++++++++++++++++++ README.md | 21 +- composer.json | 6 + docs/api/commands.rst | 22 +- docs/api/phpunit-support.rst | 24 +- docs/links/dependencies.rst | 3 + docs/usage/changelog-management.rst | 69 +++++ docs/usage/common-workflows.rst | 16 +- docs/usage/index.rst | 1 + resources/github-actions/changelog-bump.yml | 15 + resources/github-actions/release.yml | 17 ++ .../github-actions/require-changelog.yml | 15 + src/Changelog/BootstrapResult.php | 36 +++ src/Changelog/Bootstrapper.php | 114 ++++++++ src/Changelog/BootstrapperInterface.php | 32 +++ src/Changelog/CommitClassifier.php | 88 ++++++ src/Changelog/CommitClassifierInterface.php | 39 +++ src/Changelog/GitProcessRunner.php | 43 +++ src/Changelog/GitProcessRunnerInterface.php | 35 +++ src/Changelog/GitReleaseCollector.php | 129 +++++++++ .../GitReleaseCollectorInterface.php | 32 +++ src/Changelog/HistoryGenerator.php | 71 +++++ src/Changelog/HistoryGeneratorInterface.php | 32 +++ .../KeepAChangelogConfigRenderer.php | 44 +++ src/Changelog/MarkdownRenderer.php | 105 +++++++ src/Changelog/UnreleasedEntryChecker.php | 113 ++++++++ .../UnreleasedEntryCheckerInterface.php | 33 +++ src/Command/ChangelogCheckCommand.php | 83 ++++++ src/Command/ChangelogInitCommand.php | 85 ++++++ src/Command/SyncCommand.php | 9 +- .../Capability/DevToolsCommandProvider.php | 4 + tests/Changelog/BootstrapperTest.php | 141 ++++++++++ tests/Changelog/CommitClassifierTest.php | 65 +++++ tests/Changelog/GitReleaseCollectorTest.php | 86 ++++++ tests/Changelog/HistoryGeneratorTest.php | 96 +++++++ .../Changelog/UnreleasedEntryCheckerTest.php | 121 ++++++++ tests/Command/ChangelogCheckCommandTest.php | 108 +++++++ tests/Command/ChangelogInitCommandTest.php | 96 +++++++ tests/Command/SyncCommandTest.php | 4 +- .../DevToolsCommandProviderTest.php | 6 + 44 files changed, 2507 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/changelog-bump.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/require-changelog.yml create mode 100644 .keep-a-changelog.ini create mode 100644 CHANGELOG.md create mode 100644 docs/usage/changelog-management.rst create mode 100644 resources/github-actions/changelog-bump.yml create mode 100644 resources/github-actions/release.yml create mode 100644 resources/github-actions/require-changelog.yml create mode 100644 src/Changelog/BootstrapResult.php create mode 100644 src/Changelog/Bootstrapper.php create mode 100644 src/Changelog/BootstrapperInterface.php create mode 100644 src/Changelog/CommitClassifier.php create mode 100644 src/Changelog/CommitClassifierInterface.php create mode 100644 src/Changelog/GitProcessRunner.php create mode 100644 src/Changelog/GitProcessRunnerInterface.php create mode 100644 src/Changelog/GitReleaseCollector.php create mode 100644 src/Changelog/GitReleaseCollectorInterface.php create mode 100644 src/Changelog/HistoryGenerator.php create mode 100644 src/Changelog/HistoryGeneratorInterface.php create mode 100644 src/Changelog/KeepAChangelogConfigRenderer.php create mode 100644 src/Changelog/MarkdownRenderer.php create mode 100644 src/Changelog/UnreleasedEntryChecker.php create mode 100644 src/Changelog/UnreleasedEntryCheckerInterface.php create mode 100644 src/Command/ChangelogCheckCommand.php create mode 100644 src/Command/ChangelogInitCommand.php create mode 100644 tests/Changelog/BootstrapperTest.php create mode 100644 tests/Changelog/CommitClassifierTest.php create mode 100644 tests/Changelog/GitReleaseCollectorTest.php create mode 100644 tests/Changelog/HistoryGeneratorTest.php create mode 100644 tests/Changelog/UnreleasedEntryCheckerTest.php create mode 100644 tests/Command/ChangelogCheckCommandTest.php create mode 100644 tests/Command/ChangelogInitCommandTest.php diff --git a/.github/workflows/changelog-bump.yml b/.github/workflows/changelog-bump.yml new file mode 100644 index 0000000..e07edc5 --- /dev/null +++ b/.github/workflows/changelog-bump.yml @@ -0,0 +1,56 @@ +name: Bootstrap Changelog Automation + +on: + workflow_call: + workflow_dispatch: + push: + branches: ["main"] + +permissions: + contents: write + +concurrency: + group: changelog-bootstrap-${{ github.ref }} + cancel-in-progress: true + +jobs: + changelog-bootstrap: + name: Bootstrap Changelog Assets + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + uses: php-actions/composer@v6 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + COMPOSER_CACHE_DIR: /tmp/composer-cache + with: + php_version: '8.3' + command: 'install' + args: '--prefer-dist --no-progress --no-interaction --no-scripts' + + - name: Bootstrap changelog assets + run: vendor/bin/dev-tools changelog:init + + - name: Commit changelog assets + uses: EndBug/add-and-commit@v10 + with: + add: CHANGELOG.md .keep-a-changelog.ini + message: "Bootstrap changelog automation" + default_author: github_actions + pull: "--rebase --autostash" + push: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cb8d00a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Sync Release Notes from CHANGELOG + +on: + workflow_call: + inputs: + tag-name: + type: string + required: false + release: + types: [published] + +permissions: + contents: write + +jobs: + release-notes: + name: Promote CHANGELOG and Sync Release Notes + runs-on: ubuntu-latest + + steps: + - name: Checkout main branch + uses: actions/checkout@v6 + with: + ref: main + token: ${{ github.token }} + fetch-depth: 0 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + uses: php-actions/composer@v6 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + COMPOSER_CACHE_DIR: /tmp/composer-cache + with: + php_version: '8.3' + command: 'install' + args: '--prefer-dist --no-progress --no-interaction --no-scripts' + + - name: Resolve release metadata + id: release + run: | + TAG="${INPUT_TAG_NAME:-${EVENT_TAG_NAME:-${GITHUB_REF_NAME}}}" + VERSION="${TAG#v}" + DATE_VALUE="${EVENT_PUBLISHED_AT%%T*}" + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "date=${DATE_VALUE:-$(date +%F)}" >> "$GITHUB_OUTPUT" + env: + INPUT_TAG_NAME: ${{ inputs.tag-name }} + EVENT_TAG_NAME: ${{ github.event.release.tag_name }} + EVENT_PUBLISHED_AT: ${{ github.event.release.published_at }} + + - name: Promote unreleased changelog entry + run: | + VERSION="${{ steps.release.outputs.version }}" + DATE_VALUE="${{ steps.release.outputs.date }}" + + if grep -q "^## ${VERSION} - " CHANGELOG.md; then + echo "Release ${VERSION} already exists in CHANGELOG.md." + elif grep -q "^## Unreleased - " CHANGELOG.md; then + vendor/bin/keep-a-changelog unreleased:promote "${VERSION}" --date="${DATE_VALUE}" --no-interaction + else + echo "No Unreleased section found; skipping promotion." + fi + + - name: Ensure the next Unreleased section exists + run: | + if ! grep -q "^## Unreleased - " CHANGELOG.md; then + vendor/bin/keep-a-changelog unreleased:create --no-interaction + fi + + - name: Commit CHANGELOG updates + uses: EndBug/add-and-commit@v10 + with: + add: CHANGELOG.md + message: "Sync changelog for ${{ steps.release.outputs.tag }}" + default_author: github_actions + pull: "--rebase --autostash" + push: true + + - name: Export release notes from CHANGELOG + run: vendor/bin/keep-a-changelog version:show "${{ steps.release.outputs.version }}" > release-notes.md + + - name: Update GitHub release notes + run: gh release edit "${TAG}" --notes-file release-notes.md + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} diff --git a/.github/workflows/require-changelog.yml b/.github/workflows/require-changelog.yml new file mode 100644 index 0000000..77f6e7b --- /dev/null +++ b/.github/workflows/require-changelog.yml @@ -0,0 +1,58 @@ +name: Require Changelog Entry + +on: + workflow_call: + inputs: + base-ref: + type: string + required: false + default: main + pull_request: + +permissions: + contents: read + +jobs: + require-changelog: + name: Require Changelog Update + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + uses: php-actions/composer@v6 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + COMPOSER_CACHE_DIR: /tmp/composer-cache + with: + php_version: '8.3' + command: 'install' + args: '--prefer-dist --no-progress --no-interaction --no-scripts' + + - name: Resolve base reference + id: base_ref + run: echo "value=${INPUT_BASE_REF:-${GITHUB_BASE_REF:-main}}" >> "$GITHUB_OUTPUT" + env: + INPUT_BASE_REF: ${{ inputs.base-ref }} + + - name: Fetch base reference + run: git fetch origin "${BASE_REF}" --depth=1 + env: + BASE_REF: ${{ steps.base_ref.outputs.value }} + + - name: Verify unreleased changelog entries + run: vendor/bin/dev-tools changelog:check --against="refs/remotes/origin/${BASE_REF}" + env: + BASE_REF: ${{ steps.base_ref.outputs.value }} diff --git a/.keep-a-changelog.ini b/.keep-a-changelog.ini new file mode 100644 index 0000000..103e9a8 --- /dev/null +++ b/.keep-a-changelog.ini @@ -0,0 +1,7 @@ +[defaults] +changelog_file = CHANGELOG.md +provider = github +remote = origin + +[providers] +github[class] = Phly\KeepAChangelog\Provider\GitHub diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9788888 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,264 @@ +# Changelog + +All notable changes to this project will be documented in this file, in reverse chronological order by release. + +## Unreleased - TBD + +### Added + +- Add changelog bootstrap, validation commands, and reusable workflows + +### Changed + +- Sync changelog scripts and release note automation into consumer repositories + +### Deprecated + +- Nothing. + +### Removed + +- Nothing. + +### Fixed + +- Nothing. + +### Security + +- Nothing. + +## 1.4.0 - 2026-04-11 + +### Added + +- Add CoverageSummary and CoverageSummaryLoader for programmatic access to PHPUnit coverage data +- Add CoverageSummaryTest for coverage validation + +### Changed + +- Replace coverage-check dependency (#30) + +### Fixed + +- Update Symfony components to support version 8.0 + +## 1.3.0 - 2026-04-11 + +### Added + +- Add context7.json to export-ignore list +- Add comprehensive tests for GitAttributes and License components +- Create context7.json +- Expand candidate list and update docs +- Introduce GitAttributes management with Reader, Merger, and Writer implementations +- Add GitAttributesCommand to manage export-ignore rules + +### Changed + +- Isolate GitAttributesCommand as standalone command + +## 1.2.1 - 2026-04-10 + +### Added + +- Enhance documentation for license generation classes and interfaces + +### Changed + +- Update .gitattributes + +## 1.2.0 - 2026-04-10 + +### Added + +- Add command and documentation for LICENSE file generation +- Implement CopyLicenseCommand for generating LICENSE files +- Add various license files and update template loader path +- Add license file generation to dev-tools:sync +- Added support for dependency analysis with new commands and execution improvements. +- Update documentation to include details about the new skills command for synchronizing packaged agent skills +- Add tests for SkillsSynchronizer and SkillsCommand, including synchronization and link manipulation scenarios. +- Refactor SkillsSynchronizer and SkillsCommand for improved logging and synchronization handling +- Add descriptive PHPDoc to skills classes and refactor sync methods +- Add SkillsCommand to synchronize packaged skills into consumer repositories +- Add PHPDoc and PHPUnit test skills with comprehensive guidelines +- Add new skills for GitHub issue management and documentation generation + +### Changed + +- Bundle dependency analysers with dev-tools (#10) +- Add dependency analysis command (#10) +- Update branching pattern and PR title guidance in documentation + +### Fixed + +- Update command for reporting missing and unused Composer dependencies + +## 1.1.0 - 2026-04-09 + +### Added + +- Add docs for command to merge and synchronize .gitignore files +- Introduce GitIgnoreInterface and related classes for .gitignore management +- Add .gitignore sync to dev-tools:sync +- Make rector.php extensible with RectorConfig class +- Make ecs.php extensible with ECSConfig class +- Add fast-forward-github-issues skill and agent for structured issue implementation +- Add skills for generating Sphinx documentation, README files, and PHPUnit tests with guidelines and checklists + +### Changed + +- Add unit tests for ECSConfig and RectorConfig classes +- Simplify command execution and update input handling +- Add ECSConfig extension examples to documentation + +### Fixed + +- Remove pull_request trigger from reports workflow +- PHPDocs +- Add ending line to skills +- Update .editorconfig + +## 1.0.0 - 2026-04-08 + +### Added + +- Add Composer badge to README and refine project description +- Update README with additional badges and improve .gitignore and .gitattributes +- Create FUNDING.yml +- Add comprehensive documentation updates and new FAQ section +- Add pcntl extension to PHP workflow for report generation +- Add pcntl extension to PHPUnit test workflow +- Remove OrderedDocblock and related tests; update AddMissingMethodPhpDocRector to simplify docblock handling +- Refactor ByPassfinalsStartedSubscriberTest to use Instantiator for event creation +- Add symfony/var-exporter dependency to composer.json +- Add symfony/var-dumper dependency to composer.json +- Add JoliNotif and BypassFinals integration for PHPUnit notifications; update installation instructions +- Add pyrech/composer-changelogs dependency and allow plugin +- Add template option to DocsCommand and include phpdoc-bootstrap-template dependency +- Adds a filter option for running tests in the TestsCommand command. +- Adds Dependabot configuration and updates the Sync command to copy the dependabot.yml file. +- Adds the OrderedDocblock class and implements tag ordering for PHPDoc normalization. +- Adds GeneralPhpdocAnnotationRemoveFixer to the ECS configuration. + +### Changed + +- Unify post-install and post-update event handling to run sync command +- Update the Composer cache key to use composer.json instead of composer.lock in the reporting, testing, and wiki workflows. +- Updates the Git submodule path to be relative to the current working directory in the SyncCommand class. +- Replace $this->filesystem->readFile by file_get_contents on DocsCommand to avoid composer compatibility issues +- GitHub Actions(deps): Bump actions/deploy-pages from 4 to 5 +- GitHub Actions(deps): Bump actions/cache from 4 to 5 +- GitHub Actions(deps): Bump actions/checkout from 4 to 6 +- GitHub Actions(deps): Bump actions/upload-pages-artifact from 3 to 4 +- Update GitHub workflows to trigger on push and workflow_dispatch. +- Adjust the php-cs-fixer configuration to set the order of the phpdoc tags. +- Rename the installation command to 'sync' and implement the SyncCommand class to synchronize development scripts, GitHub workflows, and .editorconfig files. +- Update the installation command to use the prefix 'dev-tools:' instead of 'install'. +- Update the getDevToolsFile method to use the parent directory instead of the path to the installed package. + +### Fixed + +- Update homepage URL in composer.json to point to GitHub Pages +- Remove unnecessary parameters from PayPal donation link in FUNDING.yml +- Correct php_extensions format in workflows for reports and tests +- Reorder variable assignments in addRepositoryWikiGitSubmodule method to fix tests on ci +- Remove trailing whitespace in phpdoc command arguments + +## 1.2.2 - 2026-03-26 + +### Added + +- Adds support to ensure that the repository wiki is added as a git submodule in .github/wiki during the installation of dev-tools scripts. +- Adds support for reusable GitHub Actions workflows and updates the script installation command. +- Adds support for GrumPHP and updates script installation commands in composer.json + +### Changed + +- Update phpdocs +- Refactor methods to use getDevToolsFile in AbstractCommand and DocsCommand. +- Updates search path for GitHub Actions configuration files. +- Updates installation command to synchronize scripts, GitHub workflows, and .editorconfig files. + +### Removed + +- Remove .editorconfig from export-ignore in .gitattributes + +### Fixed + +- Fix standards +- Fix github actions +- Fix install-scripts + +## 1.0.4 - 2026-03-26 + +### Changed + +- Updates the configuration file resolution in DocsCommand and adjusts the corresponding test to accept relative paths. + +## 1.0.3 - 2026-03-26 + +### Added + +- Add package name verification to install scripts and update tests to reflect changes. + +## 1.0.2 - 2026-03-26 + +### Changed + +- Set "composer/composer" dependency to "require" index + +## 1.0.1 - 2026-03-26 + +### Added + +- Add scripts to composer.json +- Add InstallScriptsCommand +- Improve Rector docblock handling and expand test coverage for commands and Composer plugin. +- Add unit tests +- Add REAME.md +- Add PHPDoc +- Add autoload to PhpDoc command +- Add backslash + +### Changed + +- Update scripts +- Update README.md +- Update docs +- Update GitHub Pages +- Apply autostash to rebase pulling +- Update wiki +- Migrate wiki submodule to .github/wiki and update all references +- Migrate wiki submodule to .github/wiki and update references +- Update docs command +- Revert "Remove unnecessary extract" +- Replace absolute path with configuration method in ReportCommand +- Apply standards +- Update actions +- Update GitHub Actions +- Update reports +- Enhance TTY support handling +- First commit! + +### Removed + +- Remove unnecessary extract +- Remove submodules from unecessary actions + +### Fixed + +- Fix docs +- Fix composer.json bin reference +- Fix ScriptsInstallerTrait +- Fix deploy +- Fix absolute path of php-cs-fixer +- Fix input class +- Fix paths +- Fix arguments +- Fix coverage +- Fix reports deploy +- Fix coverage check +- Fix TTY GitHub Action bug + diff --git a/README.md b/README.md index ce43b3b..bfb9d43 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ across Fast Forward libraries. single Composer-facing command vocabulary - Adds dependency analysis for missing and unused Composer packages through a single report entrypoint +- Bootstraps Keep a Changelog automation, reusable release workflows, and + GitHub release note synchronization - Ships shared workflow stubs, `.editorconfig`, Dependabot configuration, and other onboarding defaults for consumer repositories - Synchronizes packaged agent skills into consumer `.agents/skills` @@ -53,6 +55,10 @@ composer dev-tools tests composer dependencies vendor/bin/dev-tools dependencies +# Bootstrap and validate Keep a Changelog assets +composer dev-tools changelog:init +composer dev-tools changelog:check + # Check and fix code style using ECS and Composer Normalize composer dev-tools code-style @@ -84,8 +90,8 @@ composer dev-tools gitattributes composer dev-tools license # Installs and synchronizes dev-tools scripts, GitHub Actions workflows, -# .editorconfig, .gitignore rules, packaged skills, and the repository wiki -# submodule in .github/wiki +# changelog automation assets, .editorconfig, .gitignore rules, packaged +# skills, and the repository wiki submodule in .github/wiki composer dev-tools:sync ``` @@ -106,10 +112,12 @@ automation assets. | `composer dev-tools` | Runs the full `standards` pipeline. | | `composer dev-tools tests` | Runs PHPUnit with local-or-packaged configuration. | | `composer dev-tools dependencies` | Reports missing and unused Composer dependencies. | +| `composer dev-tools changelog:init` | Creates local changelog automation assets. | +| `composer dev-tools changelog:check` | Verifies the `Unreleased` changelog section contains new notes. | | `composer dev-tools docs` | Builds the HTML documentation site from PSR-4 code and `docs/`. | | `composer dev-tools skills` | Creates or repairs packaged skill links in `.agents/skills`. | | `composer dev-tools gitattributes` | Manages export-ignore rules in .gitattributes. | -| `composer dev-tools:sync` | Updates scripts, workflow stubs, `.editorconfig`, `.gitignore`, `.gitattributes`, wiki setup, and packaged skills. | +| `composer dev-tools:sync` | Updates scripts, workflow stubs, changelog assets, `.editorconfig`, `.gitignore`, `.gitattributes`, wiki setup, and packaged skills. | ## šŸ”Œ Integration @@ -117,13 +125,16 @@ DevTools integrates with consumer repositories in two ways. The Composer plugin exposes the command set automatically after installation, and the local binary keeps the same command vocabulary when you prefer running tools directly from `vendor/bin/dev-tools`. The consumer sync flow also refreshes `.agents/skills` -so agents can discover the packaged skills shipped with this repository. +so agents can discover the packaged skills shipped with this repository. It +also bootstraps `.keep-a-changelog.ini`, `CHANGELOG.md`, and reusable release +workflows for changelog enforcement. ## šŸ¤ Contributing Run `composer dev-tools` before opening a pull request. If you change public commands or consumer onboarding behavior, update `README.md` and `docs/` -together so downstream libraries keep accurate guidance. +together so downstream libraries keep accurate guidance, and add a note to the +`Unreleased` section of `CHANGELOG.md`. ## šŸ“„ License diff --git a/composer.json b/composer.json index b18f188..aeb9a53 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "icanhazstring/composer-unused": "^0.9.6", "jolicode/jolinotif": "^3.3", "nikic/php-parser": "^5.7", + "phly/keep-a-changelog": "^2.13", "phpdocumentor/shim": "^3.9", "phpro/grumphp": "^2.19", "phpspec/prophecy": "^1.26", @@ -70,6 +71,7 @@ "config": { "allow-plugins": { "ergebnis/composer-normalize": true, + "php-http/discovery": true, "phpdocumentor/shim": true, "phpro/grumphp": true, "pyrech/composer-changelogs": true @@ -102,6 +104,10 @@ }, "scripts": { "dev-tools": "dev-tools", + "dev-tools:changelog:check": "@dev-tools changelog:check", + "dev-tools:changelog:init": "@dev-tools changelog:init", + "dev-tools:changelog:promote": "keep-a-changelog unreleased:promote", + "dev-tools:changelog:release": "keep-a-changelog version:release", "dev-tools:fix": "@dev-tools --fix" } } diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 07628a7..cb4c25c 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -33,6 +33,12 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch. * - ``FastForward\DevTools\Command\DependenciesCommand`` - ``dependencies`` - Reports missing and unused Composer dependencies. + * - ``FastForward\DevTools\Command\ChangelogInitCommand`` + - ``changelog:init`` + - Bootstraps ``CHANGELOG.md`` and keep-a-changelog configuration. + * - ``FastForward\DevTools\Command\ChangelogCheckCommand`` + - ``changelog:check`` + - Verifies that the ``Unreleased`` section contains meaningful notes. * - ``FastForward\DevTools\Command\DocsCommand`` - ``docs`` - Builds the HTML documentation site. @@ -47,11 +53,11 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch. - Synchronizes packaged agent skills into ``.agents/skills``. * - ``FastForward\DevTools\Command\SyncCommand`` - ``dev-tools:sync`` - - Synchronizes consumer-facing scripts, automation assets, and packaged - skills. - * - ``FastForward\DevTools\Command\GitIgnoreCommand`` - - ``gitignore`` - - Merges and synchronizes .gitignore files. - * - ``FastForward\DevTools\Command\CopyLicenseCommand`` - - ``license`` - - Generates a LICENSE file from composer.json license information. + - Synchronizes consumer-facing scripts, automation assets, changelog + workflows, and packaged skills. + * - ``FastForward\DevTools\Command\GitIgnoreCommand`` + - ``gitignore`` + - Merges and synchronizes .gitignore files. + * - ``FastForward\DevTools\Command\CopyLicenseCommand`` + - ``license`` + - Generates a LICENSE file from composer.json license information. diff --git a/docs/api/phpunit-support.rst b/docs/api/phpunit-support.rst index 1288310..1a2f9cc 100644 --- a/docs/api/phpunit-support.rst +++ b/docs/api/phpunit-support.rst @@ -19,18 +19,18 @@ The packaged test configuration includes a small integration layer under * - ``FastForward\DevTools\PhpUnit\Event\TestSuite\ByPassfinalsStartedSubscriber`` - Enables ``DG\BypassFinals`` - Allows tests to work with final constructs. - * - ``FastForward\DevTools\PhpUnit\Event\TestSuite\JoliNotifExecutionFinishedSubscriber`` - - Sends desktop notifications - - Summarizes pass, failure, error, runtime, and memory data. - * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface`` - - Loads PHPUnit coverage reports - - Contract for loading serialized PHP coverage data. - * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader`` - - Loads PHPUnit coverage reports - - Implementation that reads ``coverage-php`` output. - * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummary`` - - Represents line coverage metrics - - Provides executed lines, total executable lines, and percentage calculations. + * - ``FastForward\DevTools\PhpUnit\Event\TestSuite\JoliNotifExecutionFinishedSubscriber`` + - Sends desktop notifications + - Summarizes pass, failure, error, runtime, and memory data. + * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface`` + - Loads PHPUnit coverage reports + - Contract for loading serialized PHP coverage data. + * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader`` + - Loads PHPUnit coverage reports + - Implementation that reads ``coverage-php`` output. + * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummary`` + - Represents line coverage metrics + - Provides executed lines, total executable lines, and percentage calculations. Coverage Report Loading ----------------------- diff --git a/docs/links/dependencies.rst b/docs/links/dependencies.rst index 69d9cdd..3e15d46 100644 --- a/docs/links/dependencies.rst +++ b/docs/links/dependencies.rst @@ -55,6 +55,9 @@ Documentation and Reporting - Provides the default HTML theme used by ``docs``. * - ``esi/phpunit-coverage-check`` - Enforces the minimum coverage threshold in the reusable test workflow. + * - ``phly/keep-a-changelog`` + - Powers changelog bootstrapping, unreleased promotion, and release note + synchronization. Testing and Local Developer Experience -------------------------------------- diff --git a/docs/usage/changelog-management.rst b/docs/usage/changelog-management.rst new file mode 100644 index 0000000..eaffb4f --- /dev/null +++ b/docs/usage/changelog-management.rst @@ -0,0 +1,69 @@ +Changelog Management +==================== + +FastForward DevTools now bootstraps and enforces +`Keep a Changelog `_ workflows using +``phly/keep-a-changelog``. + +Bootstrap Once +-------------- + +Run the bootstrap command when a repository does not yet have changelog assets: + +.. code-block:: bash + + composer dev-tools changelog:init + +The command creates: + +- ``.keep-a-changelog.ini`` with local provider defaults; +- ``CHANGELOG.md`` generated from local release tags when the file is missing; +- an ``Unreleased`` section when the changelog exists but no longer tracks + pending work. + +Validate Pull Requests +---------------------- + +Use the validation command locally or in CI to ensure the ``Unreleased`` +section contains a real note: + +.. code-block:: bash + + composer dev-tools changelog:check + vendor/bin/dev-tools changelog:check --against=origin/main + +When ``--against`` is provided, the command compares the current +``Unreleased`` entries with the baseline reference and fails when no new entry +was added. + +Use the Upstream Tooling +------------------------ + +FastForward DevTools keeps the official ``keep-a-changelog`` binary available +for entry creation and release promotion: + +.. code-block:: bash + + keep-a-changelog entry:added "Document changelog automation" + keep-a-changelog unreleased:promote 1.5.0 + keep-a-changelog version:release 1.5.0 --provider-token="$GH_TOKEN" + +The synchronized Composer scripts expose the most common flows: + +- ``composer dev-tools:changelog:init`` +- ``composer dev-tools:changelog:check`` +- ``composer dev-tools:changelog:promote -- 1.5.0`` +- ``composer dev-tools:changelog:release -- 1.5.0 --provider-token=...`` + +Reusable Workflows +------------------ + +The sync command now copies three reusable workflow stubs into consumer +repositories: + +- ``changelog-bump.yml`` bootstraps ``CHANGELOG.md`` and local config on + ``main``; +- ``require-changelog.yml`` blocks pull requests without a meaningful + ``Unreleased`` entry; +- ``release.yml`` promotes ``Unreleased`` notes to the released version and + updates GitHub release notes from ``CHANGELOG.md``. diff --git a/docs/usage/common-workflows.rst b/docs/usage/common-workflows.rst index 3d78b7f..12a8999 100644 --- a/docs/usage/common-workflows.rst +++ b/docs/usage/common-workflows.rst @@ -19,6 +19,13 @@ Most day-to-day work falls into one of the flows below. * - Refresh only test results - ``composer dev-tools tests`` - Runs PHPUnit with the resolved ``phpunit.xml``. + * - Bootstrap or repair changelog automation + - ``composer dev-tools changelog:init`` + - Creates ``.keep-a-changelog.ini`` and missing ``CHANGELOG.md`` assets. + * - Verify a pull request updated the changelog + - ``composer dev-tools changelog:check`` + - Fails when the ``Unreleased`` section does not contain a meaningful + note. * - Refresh only the documentation site - ``composer dev-tools docs`` - Runs phpDocumentor using PSR-4 namespaces and the ``docs/`` guide. @@ -46,10 +53,11 @@ A Safe Beginner Routine ----------------------- 1. Run ``composer dev-tools tests``. -2. Run ``composer dev-tools skills`` if you changed packaged consumer skills. -3. Run ``composer dev-tools docs`` if you changed guides or public APIs. -4. Run ``composer dev-tools:fix`` when you want automated help. -5. Run ``composer dev-tools`` before pushing. +2. Run ``composer dev-tools changelog:check`` before opening a pull request. +3. Run ``composer dev-tools skills`` if you changed packaged consumer skills. +4. Run ``composer dev-tools docs`` if you changed guides or public APIs. +5. Run ``composer dev-tools:fix`` when you want automated help. +6. Run ``composer dev-tools`` before pushing. .. tip:: diff --git a/docs/usage/index.rst b/docs/usage/index.rst index e51f639..b0c6acd 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -8,6 +8,7 @@ task-oriented guidance instead of class-by-class reference. :maxdepth: 1 common-workflows + changelog-management testing-and-coverage documentation-workflows syncing-packaged-skills diff --git a/resources/github-actions/changelog-bump.yml b/resources/github-actions/changelog-bump.yml new file mode 100644 index 0000000..5f39dab --- /dev/null +++ b/resources/github-actions/changelog-bump.yml @@ -0,0 +1,15 @@ +name: "Fast Forward Changelog Bootstrap" + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + changelog-bootstrap: + uses: php-fast-forward/dev-tools/.github/workflows/changelog-bump.yml@main + secrets: inherit diff --git a/resources/github-actions/release.yml b/resources/github-actions/release.yml new file mode 100644 index 0000000..7f26b69 --- /dev/null +++ b/resources/github-actions/release.yml @@ -0,0 +1,17 @@ +name: "Fast Forward Release Notes Sync" + +on: + release: + types: + - published + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + uses: php-fast-forward/dev-tools/.github/workflows/release.yml@main + with: + tag-name: ${{ github.event.release.tag_name || github.ref_name }} + secrets: inherit diff --git a/resources/github-actions/require-changelog.yml b/resources/github-actions/require-changelog.yml new file mode 100644 index 0000000..f989778 --- /dev/null +++ b/resources/github-actions/require-changelog.yml @@ -0,0 +1,15 @@ +name: "Fast Forward Changelog Gate" + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + require-changelog: + uses: php-fast-forward/dev-tools/.github/workflows/require-changelog.yml@main + with: + base-ref: ${{ github.base_ref || 'main' }} + secrets: inherit diff --git a/src/Changelog/BootstrapResult.php b/src/Changelog/BootstrapResult.php new file mode 100644 index 0000000..54787aa --- /dev/null +++ b/src/Changelog/BootstrapResult.php @@ -0,0 +1,36 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +/** + * Summarizes what happened during changelog bootstrap. + */ +final readonly class BootstrapResult +{ + /** + * @param bool $configCreated + * @param bool $changelogCreated + * @param bool $unreleasedCreated + */ + public function __construct( + public bool $configCreated, + public bool $changelogCreated, + public bool $unreleasedCreated, + ) {} +} diff --git a/src/Changelog/Bootstrapper.php b/src/Changelog/Bootstrapper.php new file mode 100644 index 0000000..c460fdb --- /dev/null +++ b/src/Changelog/Bootstrapper.php @@ -0,0 +1,114 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +use function Safe\file_get_contents; +use function rtrim; +use function str_contains; +use function str_replace; +use function strpos; +use function substr; + +/** + * Creates missing keep-a-changelog configuration and bootstrap files. + */ +final readonly class Bootstrapper implements BootstrapperInterface +{ + /** + * @param Filesystem|null $filesystem + * @param HistoryGeneratorInterface|null $historyGenerator + * @param KeepAChangelogConfigRenderer|null $configRenderer + */ + public function __construct( + private ?Filesystem $filesystem = null, + private ?HistoryGeneratorInterface $historyGenerator = null, + private ?KeepAChangelogConfigRenderer $configRenderer = null, + ) {} + + /** + * @param string $workingDirectory + * + * @return BootstrapResult + */ + public function bootstrap(string $workingDirectory): BootstrapResult + { + $filesystem = $this->filesystem ?? new Filesystem(); + $configPath = Path::join($workingDirectory, '.keep-a-changelog.ini'); + $changelogPath = Path::join($workingDirectory, 'CHANGELOG.md'); + + $configCreated = false; + $changelogCreated = false; + $unreleasedCreated = false; + + if (! $filesystem->exists($configPath)) { + $filesystem->dumpFile($configPath, ($this->configRenderer ?? new KeepAChangelogConfigRenderer())->render()); + $configCreated = true; + } + + if (! $filesystem->exists($changelogPath)) { + $filesystem->dumpFile( + $changelogPath, + ($this->historyGenerator ?? new HistoryGenerator()) + ->generate($workingDirectory), + ); + $changelogCreated = true; + + return new BootstrapResult($configCreated, $changelogCreated, $unreleasedCreated); + } + + $contents = file_get_contents($changelogPath); + + if (! str_contains($contents, '## Unreleased - ')) { + $filesystem->dumpFile($changelogPath, $this->prependUnreleasedSection($contents)); + $unreleasedCreated = true; + } + + return new BootstrapResult($configCreated, $changelogCreated, $unreleasedCreated); + } + + /** + * @param string $contents + * + * @return string + */ + private function prependUnreleasedSection(string $contents): string + { + $heading = "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n"; + $unreleasedSection = "## Unreleased - TBD\n\n### Added\n\n- Nothing.\n\n### Changed\n\n- Nothing.\n\n### Deprecated\n\n- Nothing.\n\n### Removed\n\n- Nothing.\n\n### Fixed\n\n- Nothing.\n\n### Security\n\n- Nothing.\n\n"; + + $updatedContents = str_replace($heading, $heading . $unreleasedSection, $contents); + + if ($updatedContents !== $contents) { + return $updatedContents; + } + + $firstSecondaryHeadingOffset = strpos($contents, "\n## "); + + if (false === $firstSecondaryHeadingOffset) { + return rtrim($contents) . "\n\n" . $unreleasedSection; + } + + return substr($contents, 0, $firstSecondaryHeadingOffset + 1) + . $unreleasedSection + . substr($contents, $firstSecondaryHeadingOffset + 1); + } +} diff --git a/src/Changelog/BootstrapperInterface.php b/src/Changelog/BootstrapperInterface.php new file mode 100644 index 0000000..6eb9dfd --- /dev/null +++ b/src/Changelog/BootstrapperInterface.php @@ -0,0 +1,32 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +/** + * Bootstraps repository-local changelog automation artifacts. + */ +interface BootstrapperInterface +{ + /** + * @param string $workingDirectory + * + * @return BootstrapResult + */ + public function bootstrap(string $workingDirectory): BootstrapResult; +} diff --git a/src/Changelog/CommitClassifier.php b/src/Changelog/CommitClassifier.php new file mode 100644 index 0000000..a826ff3 --- /dev/null +++ b/src/Changelog/CommitClassifier.php @@ -0,0 +1,88 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use function Safe\preg_match; +use function Safe\preg_replace; +use function str_contains; +use function trim; +use function ucfirst; + +/** + * Classifies conventional and free-form commit subjects into changelog buckets. + */ +final readonly class CommitClassifier implements CommitClassifierInterface +{ + /** + * @param string $subject + * + * @return string + */ + public function classify(string $subject): string + { + $subject = trim($subject); + + if (0 !== preg_match('/\b(security|cve|vulnerability|xss|csrf)\b/i', $subject)) { + return 'Security'; + } + + if (preg_match('/^(fix|hotfix)(\(.+\))?:/i', $subject) || preg_match('/^(fix|fixed|patch)\b/i', $subject)) { + return 'Fixed'; + } + + if (preg_match('/^(feat|feature)(\(.+\))?:/i', $subject) + || preg_match('/^(add|adds|added|introduce|introduces|create|creates)\b/i', $subject) + ) { + return 'Added'; + } + + if (preg_match('/^(deprecate|deprecated)(\(.+\))?:/i', $subject) || preg_match('/^deprecat/i', $subject)) { + return 'Deprecated'; + } + + if (0 !== preg_match('/^(remove|removed|delete|deleted|drop|dropped)\b/i', $subject)) { + return 'Removed'; + } + + return 'Changed'; + } + + /** + * @param string $subject + * + * @return string + */ + public function normalize(string $subject): string + { + $subject = trim($subject); + $subject = (string) preg_replace('/^\[[^\]]+\]\s*/', '', $subject); + $subject = (string) preg_replace( + '/^(feat|feature|fix|docs|doc|refactor|chore|ci|build|style|test|tests|perf)(\([^)]+\))?:\s*/i', + '', + $subject, + ); + $subject = (string) preg_replace('/\s+/', ' ', $subject); + + if (! str_contains($subject, ' ') && preg_match('/^[a-z]/', $subject)) { + return ucfirst($subject); + } + + return 0 !== preg_match('/^[a-z]/', $subject) ? ucfirst($subject) : $subject; + } +} diff --git a/src/Changelog/CommitClassifierInterface.php b/src/Changelog/CommitClassifierInterface.php new file mode 100644 index 0000000..e899e85 --- /dev/null +++ b/src/Changelog/CommitClassifierInterface.php @@ -0,0 +1,39 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +/** + * Maps raw commit subjects to Keep a Changelog sections. + */ +interface CommitClassifierInterface +{ + /** + * @param string $subject + * + * @return string + */ + public function classify(string $subject): string; + + /** + * @param string $subject + * + * @return string + */ + public function normalize(string $subject): string; +} diff --git a/src/Changelog/GitProcessRunner.php b/src/Changelog/GitProcessRunner.php new file mode 100644 index 0000000..948ee6d --- /dev/null +++ b/src/Changelog/GitProcessRunner.php @@ -0,0 +1,43 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use Symfony\Component\Process\Process; + +use function trim; + +/** + * Executes git processes for changelog-related repository introspection. + */ +final readonly class GitProcessRunner implements GitProcessRunnerInterface +{ + /** + * @param list $command + * @param string $workingDirectory + * + * @return string + */ + public function run(array $command, string $workingDirectory): string + { + $process = new Process($command, $workingDirectory); + $process->mustRun(); + + return trim($process->getOutput()); + } +} diff --git a/src/Changelog/GitProcessRunnerInterface.php b/src/Changelog/GitProcessRunnerInterface.php new file mode 100644 index 0000000..b179f7d --- /dev/null +++ b/src/Changelog/GitProcessRunnerInterface.php @@ -0,0 +1,35 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +/** + * Executes git-aware shell commands for changelog automation services. + */ +interface GitProcessRunnerInterface +{ + /** + * Runs a command in the provided working directory and returns stdout. + * + * @param list $command + * @param string $workingDirectory + * + * @return string + */ + public function run(array $command, string $workingDirectory): string; +} diff --git a/src/Changelog/GitReleaseCollector.php b/src/Changelog/GitReleaseCollector.php new file mode 100644 index 0000000..0311cac --- /dev/null +++ b/src/Changelog/GitReleaseCollector.php @@ -0,0 +1,129 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use function Safe\preg_match; +use function array_filter; +use function array_map; +use function array_values; +use function explode; +use function str_starts_with; +use function trim; + +/** + * Reads local git tags and commit subjects to build historical changelog data. + */ +final readonly class GitReleaseCollector implements GitReleaseCollectorInterface +{ + /** + * @param GitProcessRunnerInterface $gitProcessRunner + */ + public function __construct( + private GitProcessRunnerInterface $gitProcessRunner = new GitProcessRunner() + ) {} + + /** + * @param string $workingDirectory + * + * @return list}> + */ + public function collect(string $workingDirectory): array + { + $output = $this->gitProcessRunner->run([ + 'git', + 'for-each-ref', + '--sort=creatordate', + '--format=%(refname:short)%09%(creatordate:short)', + 'refs/tags', + ], $workingDirectory); + + if ('' === $output) { + return []; + } + + $releases = []; + $previousTag = null; + + foreach (explode("\n", $output) as $line) { + [$tag, $date] = array_pad(explode("\t", trim($line), 2), 2, null); + + if (null === $tag) { + continue; + } + + if (null === $date) { + continue; + } + + if (0 === preg_match('/^v?(?\d+\.\d+\.\d+(?:[-.][A-Za-z0-9.-]+)?)$/', $tag, $matches)) { + continue; + } + + $range = null === $previousTag ? $tag : $previousTag . '..' . $tag; + $releases[] = [ + 'version' => $matches['version'], + 'tag' => $tag, + 'date' => $date, + 'commits' => $this->collectCommitSubjects($workingDirectory, $range), + ]; + + $previousTag = $tag; + } + + return $releases; + } + + /** + * @param string $workingDirectory + * @param string $range + * + * @return list + */ + private function collectCommitSubjects(string $workingDirectory, string $range): array + { + $output = $this->gitProcessRunner->run([ + 'git', + 'log', + '--format=%s', + '--no-merges', + $range, + ], $workingDirectory); + + if ('' === $output) { + return []; + } + + return array_values(array_filter(array_map( + trim(...), + explode("\n", $output), + ), fn(string $subject): bool => ! $this->shouldIgnore($subject))); + } + + /** + * @param string $subject + * + * @return bool + */ + private function shouldIgnore(string $subject): bool + { + return '' === $subject + || str_starts_with($subject, 'Merge ') + || 'Update wiki submodule pointer' === $subject; + } +} diff --git a/src/Changelog/GitReleaseCollectorInterface.php b/src/Changelog/GitReleaseCollectorInterface.php new file mode 100644 index 0000000..cf4282f --- /dev/null +++ b/src/Changelog/GitReleaseCollectorInterface.php @@ -0,0 +1,32 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +/** + * Discovers released tags and the commit subjects they contain. + */ +interface GitReleaseCollectorInterface +{ + /** + * @param string $workingDirectory + * + * @return list}> + */ + public function collect(string $workingDirectory): array; +} diff --git a/src/Changelog/HistoryGenerator.php b/src/Changelog/HistoryGenerator.php new file mode 100644 index 0000000..0be9aa9 --- /dev/null +++ b/src/Changelog/HistoryGenerator.php @@ -0,0 +1,71 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use function array_values; + +/** + * Converts release metadata and commit subjects into a rendered changelog file. + */ +final readonly class HistoryGenerator implements HistoryGeneratorInterface +{ + /** + * @param GitReleaseCollectorInterface $gitReleaseCollector + * @param CommitClassifierInterface $commitClassifier + * @param MarkdownRenderer|null $markdownRenderer + */ + public function __construct( + private GitReleaseCollectorInterface $gitReleaseCollector = new GitReleaseCollector(), + private CommitClassifierInterface $commitClassifier = new CommitClassifier(), + private ?MarkdownRenderer $markdownRenderer = null, + ) {} + + /** + * @param string $workingDirectory + * + * @return string + */ + public function generate(string $workingDirectory): string + { + $releases = []; + + foreach ($this->gitReleaseCollector->collect($workingDirectory) as $release) { + $entries = []; + + foreach ($release['commits'] as $subject) { + $section = $this->commitClassifier->classify($subject); + $entries[$section] ??= []; + $entries[$section][] = $this->commitClassifier->normalize($subject); + } + + foreach ($entries as $section => $sectionEntries) { + $entries[$section] = array_values(array_unique($sectionEntries)); + } + + $releases[] = [ + 'version' => $release['version'], + 'date' => $release['date'], + 'entries' => $entries, + ]; + } + + return ($this->markdownRenderer ?? new MarkdownRenderer()) + ->render($releases); + } +} diff --git a/src/Changelog/HistoryGeneratorInterface.php b/src/Changelog/HistoryGeneratorInterface.php new file mode 100644 index 0000000..6da6c20 --- /dev/null +++ b/src/Changelog/HistoryGeneratorInterface.php @@ -0,0 +1,32 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +/** + * Builds initial changelog markdown from repository release history. + */ +interface HistoryGeneratorInterface +{ + /** + * @param string $workingDirectory + * + * @return string + */ + public function generate(string $workingDirectory): string; +} diff --git a/src/Changelog/KeepAChangelogConfigRenderer.php b/src/Changelog/KeepAChangelogConfigRenderer.php new file mode 100644 index 0000000..622bbf3 --- /dev/null +++ b/src/Changelog/KeepAChangelogConfigRenderer.php @@ -0,0 +1,44 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use function implode; + +/** + * Renders the repository-local keep-a-changelog configuration file. + */ +final readonly class KeepAChangelogConfigRenderer +{ + /** + * @return string + */ + public function render(): string + { + return implode("\n", [ + '[defaults]', + 'changelog_file = CHANGELOG.md', + 'provider = github', + 'remote = origin', + '', + '[providers]', + 'github[class] = Phly\KeepAChangelog\Provider\GitHub', + '', + ]); + } +} diff --git a/src/Changelog/MarkdownRenderer.php b/src/Changelog/MarkdownRenderer.php new file mode 100644 index 0000000..9ceefaf --- /dev/null +++ b/src/Changelog/MarkdownRenderer.php @@ -0,0 +1,105 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use function array_reverse; +use function implode; + +/** + * Renders Keep a Changelog markdown in a deterministic package-friendly format. + */ +final readonly class MarkdownRenderer +{ + /** + * @var list + */ + private const array SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']; + + /** + * @param list>}> $releases + * + * @return string + */ + public function render(array $releases): string + { + $lines = [ + '# Changelog', + '', + 'All notable changes to this project will be documented in this file, in reverse chronological order by release.', + '', + ]; + + $lines = [...$lines, ...$this->renderSection('Unreleased', 'TBD', [])]; + + foreach (array_reverse($releases) as $release) { + $lines = [ + ...$lines, + ...$this->renderSection($release['version'], $release['date'], $release['entries']), + ]; + } + + return implode("\n", $lines) . "\n"; + } + + /** + * @param string $version + * @param string $date + * @param array> $entries + * + * @return list + */ + private function renderSection(string $version, string $date, array $entries): array + { + $lines = ['## ' . $version . ' - ' . $date, '']; + $hasEntries = false; + + foreach (self::SECTION_ORDER as $section) { + $sectionEntries = $entries[$section] ?? []; + + if ('Unreleased' !== $version && [] === $sectionEntries) { + continue; + } + + $hasEntries = true; + $lines[] = '### ' . $section; + $lines[] = ''; + + if ([] === $sectionEntries) { + $lines[] = '- Nothing.'; + $lines[] = ''; + continue; + } + + foreach ($sectionEntries as $entry) { + $lines[] = '- ' . $entry; + } + + $lines[] = ''; + } + + if (! $hasEntries) { + $lines[] = '### Changed'; + $lines[] = ''; + $lines[] = '- Nothing.'; + $lines[] = ''; + } + + return $lines; + } +} diff --git a/src/Changelog/UnreleasedEntryChecker.php b/src/Changelog/UnreleasedEntryChecker.php new file mode 100644 index 0000000..f4e4ecf --- /dev/null +++ b/src/Changelog/UnreleasedEntryChecker.php @@ -0,0 +1,113 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use Symfony\Component\Filesystem\Path; +use Throwable; + +use function Safe\preg_match; +use function Safe\preg_split; +use function Safe\file_get_contents; +use function array_diff; +use function array_filter; +use function array_map; +use function array_values; +use function trim; + +/** + * Compares unreleased changelog entries against the current branch or a base ref. + */ +final readonly class UnreleasedEntryChecker implements UnreleasedEntryCheckerInterface +{ + /** + * @param GitProcessRunnerInterface $gitProcessRunner + */ + public function __construct( + private GitProcessRunnerInterface $gitProcessRunner = new GitProcessRunner() + ) {} + + /** + * @param string $workingDirectory + * @param string|null $againstReference + * + * @return bool + */ + public function hasPendingChanges(string $workingDirectory, ?string $againstReference = null): bool + { + $currentPath = Path::join($workingDirectory, 'CHANGELOG.md'); + + if (! is_file($currentPath)) { + return false; + } + + $currentEntries = $this->extractEntries(file_get_contents($currentPath)); + + if ([] === $currentEntries) { + return false; + } + + if (null === $againstReference) { + return true; + } + + try { + $baseline = $this->gitProcessRunner->run([ + 'git', + 'show', + $againstReference . ':CHANGELOG.md', + ], $workingDirectory); + } catch (Throwable) { + return true; + } + + $baselineEntries = $this->extractEntries($baseline); + + return [] !== array_values(array_diff($currentEntries, $baselineEntries)); + } + + /** + * @param string $contents + * + * @return list + */ + private function extractEntries(string $contents): array + { + if (0 === preg_match('/^## Unreleased\s+-\s+.+?(?=^##\s|\z)/ms', $contents, $matches)) { + return []; + } + + $lines = preg_split('/\R/', trim($matches[0])); + + return array_values(array_filter(array_map(static function (string $line): ?string { + $line = trim($line); + + if (0 === preg_match('/^- (.+)$/', $line, $matches)) { + return null; + } + + $entry = trim($matches[1]); + + if ('Nothing.' === $entry) { + return null; + } + + return $entry; + }, $lines))); + } +} diff --git a/src/Changelog/UnreleasedEntryCheckerInterface.php b/src/Changelog/UnreleasedEntryCheckerInterface.php new file mode 100644 index 0000000..963bf09 --- /dev/null +++ b/src/Changelog/UnreleasedEntryCheckerInterface.php @@ -0,0 +1,33 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +/** + * Verifies that the changelog contains meaningful unreleased changes. + */ +interface UnreleasedEntryCheckerInterface +{ + /** + * @param string $workingDirectory + * @param string|null $againstReference + * + * @return bool + */ + public function hasPendingChanges(string $workingDirectory, ?string $againstReference = null): bool; +} diff --git a/src/Command/ChangelogCheckCommand.php b/src/Command/ChangelogCheckCommand.php new file mode 100644 index 0000000..f960e1a --- /dev/null +++ b/src/Command/ChangelogCheckCommand.php @@ -0,0 +1,83 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command; + +use FastForward\DevTools\Changelog\UnreleasedEntryChecker; +use FastForward\DevTools\Changelog\UnreleasedEntryCheckerInterface; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Verifies that the changelog contains pending unreleased notes. + */ +final class ChangelogCheckCommand extends AbstractCommand +{ + /** + * @param Filesystem|null $filesystem + * @param UnreleasedEntryCheckerInterface|null $unreleasedEntryChecker + */ + public function __construct( + ?Filesystem $filesystem = null, + private readonly ?UnreleasedEntryCheckerInterface $unreleasedEntryChecker = null, + ) { + parent::__construct($filesystem); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->setName('changelog:check') + ->setDescription('Checks whether CHANGELOG.md contains meaningful unreleased entries.') + ->setHelp( + 'This command validates the current Unreleased section and may compare it against a base git reference to enforce pull request changelog updates.' + ) + ->addOption( + name: 'against', + mode: InputOption::VALUE_REQUIRED, + description: 'Optional git reference used as the baseline CHANGELOG.md.', + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $hasPendingChanges = ($this->unreleasedEntryChecker ?? new UnreleasedEntryChecker()) + ->hasPendingChanges($this->getCurrentWorkingDirectory(), $input->getOption('against')); + + if ($hasPendingChanges) { + $output->writeln('CHANGELOG.md contains unreleased changes ready for review.'); + + return self::SUCCESS; + } + + $output->writeln('CHANGELOG.md must add a meaningful entry to the Unreleased section.'); + + return self::FAILURE; + } +} diff --git a/src/Command/ChangelogInitCommand.php b/src/Command/ChangelogInitCommand.php new file mode 100644 index 0000000..293c985 --- /dev/null +++ b/src/Command/ChangelogInitCommand.php @@ -0,0 +1,85 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command; + +use FastForward\DevTools\Changelog\Bootstrapper; +use FastForward\DevTools\Changelog\BootstrapperInterface; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Bootstraps keep-a-changelog assets for the current repository. + */ +final class ChangelogInitCommand extends AbstractCommand +{ + /** + * @param Filesystem|null $filesystem + * @param BootstrapperInterface|null $bootstrapper + */ + public function __construct( + ?Filesystem $filesystem = null, + private readonly ?BootstrapperInterface $bootstrapper = null, + ) { + parent::__construct($filesystem); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->setName('changelog:init') + ->setDescription('Bootstraps keep-a-changelog configuration and CHANGELOG.md.') + ->setHelp( + 'This command creates .keep-a-changelog.ini, generates CHANGELOG.md from git release history when missing, and restores an Unreleased section when necessary.' + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $result = ($this->bootstrapper ?? new Bootstrapper($this->filesystem)) + ->bootstrap($this->getCurrentWorkingDirectory()); + + if ($result->configCreated) { + $output->writeln('Created .keep-a-changelog.ini.'); + } + + if ($result->changelogCreated) { + $output->writeln('Generated CHANGELOG.md from repository history.'); + } + + if ($result->unreleasedCreated) { + $output->writeln('Restored an Unreleased section in CHANGELOG.md.'); + } + + if (! $result->configCreated && ! $result->changelogCreated && ! $result->unreleasedCreated) { + $output->writeln('Changelog automation assets are already up to date.'); + } + + return self::SUCCESS; + } +} diff --git a/src/Command/SyncCommand.php b/src/Command/SyncCommand.php index d2f39cb..21581e4 100644 --- a/src/Command/SyncCommand.php +++ b/src/Command/SyncCommand.php @@ -47,10 +47,10 @@ protected function configure(): void $this ->setName('dev-tools:sync') ->setDescription( - 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and .gitattributes in the root project.' + 'Installs and synchronizes dev-tools scripts, reusable workflows, changelog assets, .editorconfig, and archive metadata in the root project.' ) ->setHelp( - 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.' + 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, bootstraps changelog automation assets, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.' ); } @@ -78,6 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->runCommand('gitattributes', $output); $this->runCommand('skills', $output); $this->runCommand('license', $output); + $this->runCommand('changelog:init', $output); return self::SUCCESS; } @@ -104,6 +105,10 @@ private function updateComposerJson(): void $scripts = [ 'dev-tools' => 'dev-tools', 'dev-tools:fix' => '@dev-tools --fix', + 'dev-tools:changelog:init' => '@dev-tools changelog:init', + 'dev-tools:changelog:check' => '@dev-tools changelog:check', + 'dev-tools:changelog:promote' => 'keep-a-changelog unreleased:promote', + 'dev-tools:changelog:release' => 'keep-a-changelog version:release', ]; $extra = [ diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php index f356b50..d17eeaf 100644 --- a/src/Composer/Capability/DevToolsCommandProvider.php +++ b/src/Composer/Capability/DevToolsCommandProvider.php @@ -24,6 +24,8 @@ use FastForward\DevTools\Command\CopyLicenseCommand; use FastForward\DevTools\Command\DependenciesCommand; use FastForward\DevTools\Command\DocsCommand; +use FastForward\DevTools\Command\ChangelogInitCommand; +use FastForward\DevTools\Command\ChangelogCheckCommand; use FastForward\DevTools\Command\GitAttributesCommand; use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\PhpDocCommand; @@ -56,6 +58,8 @@ public function getCommands() new RefactorCommand(), new TestsCommand(), new DependenciesCommand(), + new ChangelogInitCommand(), + new ChangelogCheckCommand(), new PhpDocCommand(), new DocsCommand(), new StandardsCommand(), diff --git a/tests/Changelog/BootstrapperTest.php b/tests/Changelog/BootstrapperTest.php new file mode 100644 index 0000000..4af14be --- /dev/null +++ b/tests/Changelog/BootstrapperTest.php @@ -0,0 +1,141 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\Bootstrapper; +use FastForward\DevTools\Changelog\BootstrapResult; +use FastForward\DevTools\Changelog\HistoryGeneratorInterface; +use FastForward\DevTools\Changelog\KeepAChangelogConfigRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; + +use function Safe\file_get_contents; +use function Safe\mkdir; +use function uniqid; +use function sys_get_temp_dir; + +#[CoversClass(Bootstrapper::class)] +#[UsesClass(BootstrapResult::class)] +#[UsesClass(KeepAChangelogConfigRenderer::class)] +final class BootstrapperTest extends TestCase +{ + private Filesystem $filesystem; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->filesystem = new Filesystem(); + } + + /** + * @return void + */ + #[Test] + public function bootstrapWillCreateMissingConfigAndChangelogFiles(): void + { + $workingDirectory = $this->createTemporaryDirectory(); + $historyGenerator = new class implements HistoryGeneratorInterface { + /** + * @param string $workingDirectory + * + * @return string + */ + public function generate(string $workingDirectory): string + { + return "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n## Unreleased - TBD\n\n### Added\n\n- Nothing.\n"; + } + }; + + $result = new Bootstrapper($this->filesystem, $historyGenerator) + ->bootstrap($workingDirectory); + + self::assertTrue($result->configCreated); + self::assertTrue($result->changelogCreated); + self::assertFalse($result->unreleasedCreated); + self::assertFileExists($workingDirectory . '/.keep-a-changelog.ini'); + self::assertFileExists($workingDirectory . '/CHANGELOG.md'); + } + + /** + * @return void + */ + #[Test] + public function bootstrapWillRestoreMissingUnreleasedSection(): void + { + $workingDirectory = $this->createTemporaryDirectory(); + + $this->filesystem->dumpFile( + $workingDirectory . '/CHANGELOG.md', + "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n", + ); + $this->filesystem->dumpFile($workingDirectory . '/.keep-a-changelog.ini', "[defaults]\n"); + + $result = new Bootstrapper($this->filesystem) + ->bootstrap($workingDirectory); + + self::assertFalse($result->configCreated); + self::assertFalse($result->changelogCreated); + self::assertTrue($result->unreleasedCreated); + self::assertStringContainsString('## Unreleased - TBD', file_get_contents($workingDirectory . '/CHANGELOG.md')); + } + + /** + * @return void + */ + #[Test] + public function bootstrapWillRestoreMissingUnreleasedSectionForExistingCustomIntro(): void + { + $workingDirectory = $this->createTemporaryDirectory(); + + $this->filesystem->dumpFile( + $workingDirectory . '/CHANGELOG.md', + "# Changelog\n\nProject-specific introduction.\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n", + ); + $this->filesystem->dumpFile($workingDirectory . '/.keep-a-changelog.ini', "[defaults]\n"); + + $result = new Bootstrapper($this->filesystem) + ->bootstrap($workingDirectory); + + self::assertFalse($result->configCreated); + self::assertFalse($result->changelogCreated); + self::assertTrue($result->unreleasedCreated); + self::assertStringContainsString( + "Project-specific introduction.\n\n## Unreleased - TBD\n\n### Added", + file_get_contents($workingDirectory . '/CHANGELOG.md'), + ); + } + + /** + * @return string + */ + private function createTemporaryDirectory(): string + { + $directory = sys_get_temp_dir() . '/' . uniqid('dev-tools-changelog-', true); + mkdir($directory); + + return $directory; + } +} diff --git a/tests/Changelog/CommitClassifierTest.php b/tests/Changelog/CommitClassifierTest.php new file mode 100644 index 0000000..f42c4bf --- /dev/null +++ b/tests/Changelog/CommitClassifierTest.php @@ -0,0 +1,65 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\CommitClassifier; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(CommitClassifier::class)] +final class CommitClassifierTest extends TestCase +{ + private CommitClassifier $commitClassifier; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->commitClassifier = new CommitClassifier(); + } + + /** + * @return void + */ + #[Test] + public function classifyWillMapSupportedCommitPrefixesToExpectedSections(): void + { + self::assertSame('Added', $this->commitClassifier->classify('feat(command): add changelog command')); + self::assertSame('Fixed', $this->commitClassifier->classify('fix(workflow): guard against missing token')); + self::assertSame('Removed', $this->commitClassifier->classify('remove deprecated bootstrap path')); + self::assertSame('Security', $this->commitClassifier->classify('fix: patch security token leak')); + self::assertSame('Changed', $this->commitClassifier->classify('docs: explain changelog workflow')); + } + + /** + * @return void + */ + #[Test] + public function normalizeWillStripConventionalPrefixesAndBracketedAreas(): void + { + self::assertSame( + 'Add changelog bootstrap command (#28)', + $this->commitClassifier->normalize('[command] feat(changelog): add changelog bootstrap command (#28)'), + ); + } +} diff --git a/tests/Changelog/GitReleaseCollectorTest.php b/tests/Changelog/GitReleaseCollectorTest.php new file mode 100644 index 0000000..17d60f6 --- /dev/null +++ b/tests/Changelog/GitReleaseCollectorTest.php @@ -0,0 +1,86 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\GitProcessRunnerInterface; +use FastForward\DevTools\Changelog\GitReleaseCollector; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(GitReleaseCollector::class)] +final class GitReleaseCollectorTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $gitProcessRunner; + + private GitReleaseCollector $gitReleaseCollector; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->gitProcessRunner = $this->prophesize(GitProcessRunnerInterface::class); + $this->gitReleaseCollector = new GitReleaseCollector($this->gitProcessRunner->reveal()); + } + + /** + * @return void + */ + #[Test] + public function collectWillReturnReleaseRangesWithFilteredCommitSubjects(): void + { + $workingDirectory = '/tmp/project'; + + $this->gitProcessRunner->run( + Argument::that(static fn(array $command): bool => \in_array('for-each-ref', $command, true)), + $workingDirectory + ) + ->willReturn("v1.0.0\t2026-04-08\nbeta\t2026-04-09\nv1.1.0\t2026-04-10"); + $this->gitProcessRunner->run(['git', 'log', '--format=%s', '--no-merges', 'v1.0.0'], $workingDirectory) + ->willReturn("feat: add bootstrap command\nUpdate wiki submodule pointer\n"); + $this->gitProcessRunner->run(['git', 'log', '--format=%s', '--no-merges', 'v1.0.0..v1.1.0'], $workingDirectory) + ->willReturn("fix: validate unreleased notes\nMerge pull request #10 from feature\n"); + + self::assertSame([ + [ + 'version' => '1.0.0', + 'tag' => 'v1.0.0', + 'date' => '2026-04-08', + 'commits' => ['feat: add bootstrap command'], + ], + [ + 'version' => '1.1.0', + 'tag' => 'v1.1.0', + 'date' => '2026-04-10', + 'commits' => ['fix: validate unreleased notes'], + ], + ], $this->gitReleaseCollector->collect($workingDirectory)); + } +} diff --git a/tests/Changelog/HistoryGeneratorTest.php b/tests/Changelog/HistoryGeneratorTest.php new file mode 100644 index 0000000..558dfde --- /dev/null +++ b/tests/Changelog/HistoryGeneratorTest.php @@ -0,0 +1,96 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\CommitClassifierInterface; +use FastForward\DevTools\Changelog\GitReleaseCollectorInterface; +use FastForward\DevTools\Changelog\HistoryGenerator; +use FastForward\DevTools\Changelog\MarkdownRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(HistoryGenerator::class)] +#[UsesClass(MarkdownRenderer::class)] +final class HistoryGeneratorTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $gitReleaseCollector; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $commitClassifier; + + private HistoryGenerator $historyGenerator; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->gitReleaseCollector = $this->prophesize(GitReleaseCollectorInterface::class); + $this->commitClassifier = $this->prophesize(CommitClassifierInterface::class); + $this->historyGenerator = new HistoryGenerator( + $this->gitReleaseCollector->reveal(), + $this->commitClassifier->reveal(), + ); + } + + /** + * @return void + */ + #[Test] + public function generateWillRenderCollectedReleaseHistoryAsMarkdown(): void + { + $this->gitReleaseCollector->collect('/tmp/project') + ->willReturn([ + [ + 'version' => '1.0.0', + 'tag' => 'v1.0.0', + 'date' => '2026-04-08', + 'commits' => ['feat: add bootstrap', 'fix: validate changelog'], + ], + ]); + $this->commitClassifier->classify('feat: add bootstrap') + ->willReturn('Added'); + $this->commitClassifier->normalize('feat: add bootstrap') + ->willReturn('Add bootstrap'); + $this->commitClassifier->classify('fix: validate changelog') + ->willReturn('Fixed'); + $this->commitClassifier->normalize('fix: validate changelog') + ->willReturn('Validate changelog'); + + $markdown = $this->historyGenerator->generate('/tmp/project'); + + self::assertStringContainsString('## Unreleased - TBD', $markdown); + self::assertStringContainsString('## 1.0.0 - 2026-04-08', $markdown); + self::assertStringContainsString('- Add bootstrap', $markdown); + self::assertStringContainsString('- Validate changelog', $markdown); + } +} diff --git a/tests/Changelog/UnreleasedEntryCheckerTest.php b/tests/Changelog/UnreleasedEntryCheckerTest.php new file mode 100644 index 0000000..87b26ea --- /dev/null +++ b/tests/Changelog/UnreleasedEntryCheckerTest.php @@ -0,0 +1,121 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\GitProcessRunnerInterface; +use FastForward\DevTools\Changelog\UnreleasedEntryChecker; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +use function Safe\file_put_contents; +use function Safe\mkdir; +use function uniqid; +use function sys_get_temp_dir; + +#[CoversClass(UnreleasedEntryChecker::class)] +final class UnreleasedEntryCheckerTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $gitProcessRunner; + + private string $workingDirectory; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->gitProcessRunner = $this->prophesize(GitProcessRunnerInterface::class); + $this->workingDirectory = sys_get_temp_dir() . '/' . uniqid('dev-tools-checker-', true); + mkdir($this->workingDirectory); + } + + /** + * @return void + */ + #[Test] + public function hasPendingChangesWillReturnTrueWhenUnreleasedSectionContainsEntries(): void + { + file_put_contents( + $this->workingDirectory . '/CHANGELOG.md', + $this->createChangelog('- Added changelog automation.') + ); + + self::assertTrue( + (new UnreleasedEntryChecker($this->gitProcessRunner->reveal()))->hasPendingChanges($this->workingDirectory), + ); + } + + /** + * @return void + */ + #[Test] + public function hasPendingChangesWillCompareAgainstBaselineReference(): void + { + file_put_contents( + $this->workingDirectory . '/CHANGELOG.md', + $this->createChangelog('- Added changelog automation.') + ); + $this->gitProcessRunner->run(['git', 'show', 'origin/main:CHANGELOG.md'], $this->workingDirectory) + ->willReturn($this->createChangelog('- Added changelog automation.')); + + self::assertFalse( + (new UnreleasedEntryChecker($this->gitProcessRunner->reveal())) + ->hasPendingChanges($this->workingDirectory, 'origin/main'), + ); + } + + /** + * @return void + */ + #[Test] + public function hasPendingChangesWillReturnTrueWhenBaselineDoesNotContainNewEntries(): void + { + file_put_contents( + $this->workingDirectory . '/CHANGELOG.md', + $this->createChangelog('- Added changelog automation.') + ); + $this->gitProcessRunner->run(['git', 'show', 'origin/main:CHANGELOG.md'], $this->workingDirectory) + ->willReturn($this->createChangelog('- Nothing.')); + + self::assertTrue( + (new UnreleasedEntryChecker($this->gitProcessRunner->reveal())) + ->hasPendingChanges($this->workingDirectory, 'origin/main'), + ); + } + + /** + * @param string $entry + * + * @return string + */ + private function createChangelog(string $entry): string + { + return "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n## Unreleased - TBD\n\n### Added\n\n{$entry}\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n"; + } +} diff --git a/tests/Command/ChangelogCheckCommandTest.php b/tests/Command/ChangelogCheckCommandTest.php new file mode 100644 index 0000000..ad4e058 --- /dev/null +++ b/tests/Command/ChangelogCheckCommandTest.php @@ -0,0 +1,108 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Command; + +use FastForward\DevTools\Changelog\UnreleasedEntryCheckerInterface; +use FastForward\DevTools\Command\ChangelogCheckCommand; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(ChangelogCheckCommand::class)] +final class ChangelogCheckCommandTest extends AbstractCommandTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $unreleasedEntryChecker; + + /** + * @return void + */ + protected function setUp(): void + { + $this->unreleasedEntryChecker = $this->prophesize(UnreleasedEntryCheckerInterface::class); + + parent::setUp(); + } + + /** + * @return ChangelogCheckCommand + */ + protected function getCommandClass(): ChangelogCheckCommand + { + return new ChangelogCheckCommand($this->filesystem->reveal(), $this->unreleasedEntryChecker->reveal()); + } + + /** + * @return string + */ + protected function getCommandName(): string + { + return 'changelog:check'; + } + + /** + * @return string + */ + protected function getCommandDescription(): string + { + return 'Checks whether CHANGELOG.md contains meaningful unreleased entries.'; + } + + /** + * @return string + */ + protected function getCommandHelp(): string + { + return 'This command validates the current Unreleased section and may compare it against a base git reference to enforce pull request changelog updates.'; + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnSuccessWhenUnreleasedEntriesExist(): void + { + $this->unreleasedEntryChecker->hasPendingChanges(Argument::type('string'), null) + ->willReturn(true); + $this->output->writeln(Argument::containingString('ready for review')) + ->shouldBeCalled(); + + self::assertSame(ChangelogCheckCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnFailureWhenUnreleasedEntriesAreMissing(): void + { + $this->unreleasedEntryChecker->hasPendingChanges(Argument::type('string'), null) + ->willReturn(false); + $this->output->writeln(Argument::containingString('must add a meaningful entry')) + ->shouldBeCalled(); + + self::assertSame(ChangelogCheckCommand::FAILURE, $this->invokeExecute()); + } +} diff --git a/tests/Command/ChangelogInitCommandTest.php b/tests/Command/ChangelogInitCommandTest.php new file mode 100644 index 0000000..c6dc91a --- /dev/null +++ b/tests/Command/ChangelogInitCommandTest.php @@ -0,0 +1,96 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Command; + +use FastForward\DevTools\Changelog\BootstrapperInterface; +use FastForward\DevTools\Changelog\BootstrapResult; +use FastForward\DevTools\Command\ChangelogInitCommand; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(ChangelogInitCommand::class)] +#[UsesClass(BootstrapResult::class)] +final class ChangelogInitCommandTest extends AbstractCommandTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $bootstrapper; + + /** + * @return void + */ + protected function setUp(): void + { + $this->bootstrapper = $this->prophesize(BootstrapperInterface::class); + + parent::setUp(); + } + + /** + * @return ChangelogInitCommand + */ + protected function getCommandClass(): ChangelogInitCommand + { + return new ChangelogInitCommand($this->filesystem->reveal(), $this->bootstrapper->reveal()); + } + + /** + * @return string + */ + protected function getCommandName(): string + { + return 'changelog:init'; + } + + /** + * @return string + */ + protected function getCommandDescription(): string + { + return 'Bootstraps keep-a-changelog configuration and CHANGELOG.md.'; + } + + /** + * @return string + */ + protected function getCommandHelp(): string + { + return 'This command creates .keep-a-changelog.ini, generates CHANGELOG.md from git release history when missing, and restores an Unreleased section when necessary.'; + } + + /** + * @return void + */ + #[Test] + public function executeWillReportCreatedArtifacts(): void + { + $this->bootstrapper->bootstrap(Argument::type('string')) + ->willReturn(new BootstrapResult(true, true, false)); + $this->output->writeln(Argument::type('string'))->shouldBeCalledTimes(2); + + self::assertSame(ChangelogInitCommand::SUCCESS, $this->invokeExecute()); + } +} diff --git a/tests/Command/SyncCommandTest.php b/tests/Command/SyncCommandTest.php index e429f50..705fba2 100644 --- a/tests/Command/SyncCommandTest.php +++ b/tests/Command/SyncCommandTest.php @@ -71,7 +71,7 @@ protected function getCommandName(): string */ protected function getCommandDescription(): string { - return 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and .gitattributes in the root project.'; + return 'Installs and synchronizes dev-tools scripts, reusable workflows, changelog assets, .editorconfig, and archive metadata in the root project.'; } /** @@ -79,7 +79,7 @@ protected function getCommandDescription(): string */ protected function getCommandHelp(): string { - return 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.'; + return 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, bootstraps changelog automation assets, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.'; } /** diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 81a7783..2eec1ec 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -19,6 +19,8 @@ namespace FastForward\DevTools\Tests\Composer\Capability; use FastForward\DevTools\Command\AbstractCommand; +use FastForward\DevTools\Command\ChangelogCheckCommand; +use FastForward\DevTools\Command\ChangelogInitCommand; use FastForward\DevTools\Command\CodeStyleCommand; use FastForward\DevTools\Command\CopyLicenseCommand; use FastForward\DevTools\Command\DependenciesCommand; @@ -49,6 +51,8 @@ use PHPUnit\Framework\TestCase; #[CoversClass(DevToolsCommandProvider::class)] +#[UsesClass(ChangelogInitCommand::class)] +#[UsesClass(ChangelogCheckCommand::class)] #[UsesClass(CodeStyleCommand::class)] #[UsesClass(RefactorCommand::class)] #[UsesClass(TestsCommand::class)] @@ -96,6 +100,8 @@ public function getCommandsWillReturnAllSupportedCommandsInExpectedOrder(): void new RefactorCommand(), new TestsCommand(), new DependenciesCommand(), + new ChangelogInitCommand(), + new ChangelogCheckCommand(), new PhpDocCommand(), new DocsCommand(), new StandardsCommand(), From 5528070813d8552f4001010c7dea047ed07b6899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 21:16:20 -0300 Subject: [PATCH 02/11] fix(workflows): update changelog commands to use php-actions/composer with PHP 8.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- .github/workflows/changelog-bump.yml | 7 ++++++- .github/workflows/require-changelog.yml | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changelog-bump.yml b/.github/workflows/changelog-bump.yml index e07edc5..450cc01 100644 --- a/.github/workflows/changelog-bump.yml +++ b/.github/workflows/changelog-bump.yml @@ -44,7 +44,12 @@ jobs: args: '--prefer-dist --no-progress --no-interaction --no-scripts' - name: Bootstrap changelog assets - run: vendor/bin/dev-tools changelog:init + uses: php-actions/composer@v6 + with: + php_version: '8.3' + php_extensions: pcov pcntl + command: 'dev-tools' + args: 'changelog:init' - name: Commit changelog assets uses: EndBug/add-and-commit@v10 diff --git a/.github/workflows/require-changelog.yml b/.github/workflows/require-changelog.yml index 77f6e7b..2351b97 100644 --- a/.github/workflows/require-changelog.yml +++ b/.github/workflows/require-changelog.yml @@ -53,6 +53,11 @@ jobs: BASE_REF: ${{ steps.base_ref.outputs.value }} - name: Verify unreleased changelog entries - run: vendor/bin/dev-tools changelog:check --against="refs/remotes/origin/${BASE_REF}" + uses: php-actions/composer@v6 env: BASE_REF: ${{ steps.base_ref.outputs.value }} + with: + php_version: '8.3' + php_extensions: pcov pcntl + command: 'dev-tools' + args: 'changelog:check -- --against="refs/remotes/origin/${BASE_REF}"' From 040a6892d00e2ebfc8297f4d47087c7d27d1645c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 21:16:33 -0300 Subject: [PATCH 03/11] refactor: remove outdated changelog scripts from composer.json and SyncCommand.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- composer.json | 4 ---- docs/usage/changelog-management.rst | 2 -- src/Command/SyncCommand.php | 4 ---- 3 files changed, 10 deletions(-) diff --git a/composer.json b/composer.json index aeb9a53..a84c27c 100644 --- a/composer.json +++ b/composer.json @@ -104,10 +104,6 @@ }, "scripts": { "dev-tools": "dev-tools", - "dev-tools:changelog:check": "@dev-tools changelog:check", - "dev-tools:changelog:init": "@dev-tools changelog:init", - "dev-tools:changelog:promote": "keep-a-changelog unreleased:promote", - "dev-tools:changelog:release": "keep-a-changelog version:release", "dev-tools:fix": "@dev-tools --fix" } } diff --git a/docs/usage/changelog-management.rst b/docs/usage/changelog-management.rst index eaffb4f..6c002b6 100644 --- a/docs/usage/changelog-management.rst +++ b/docs/usage/changelog-management.rst @@ -50,8 +50,6 @@ for entry creation and release promotion: The synchronized Composer scripts expose the most common flows: -- ``composer dev-tools:changelog:init`` -- ``composer dev-tools:changelog:check`` - ``composer dev-tools:changelog:promote -- 1.5.0`` - ``composer dev-tools:changelog:release -- 1.5.0 --provider-token=...`` diff --git a/src/Command/SyncCommand.php b/src/Command/SyncCommand.php index 21581e4..de26b7d 100644 --- a/src/Command/SyncCommand.php +++ b/src/Command/SyncCommand.php @@ -105,10 +105,6 @@ private function updateComposerJson(): void $scripts = [ 'dev-tools' => 'dev-tools', 'dev-tools:fix' => '@dev-tools --fix', - 'dev-tools:changelog:init' => '@dev-tools changelog:init', - 'dev-tools:changelog:check' => '@dev-tools changelog:check', - 'dev-tools:changelog:promote' => 'keep-a-changelog unreleased:promote', - 'dev-tools:changelog:release' => 'keep-a-changelog version:release', ]; $extra = [ From 47416df196e1bcca0ecdc3951207d7576b9eeae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 21:19:48 -0300 Subject: [PATCH 04/11] fix(tests): instantiate Bootstrapper in a single expression for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- tests/Changelog/BootstrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Changelog/BootstrapperTest.php b/tests/Changelog/BootstrapperTest.php index 4af14be..d22f18d 100644 --- a/tests/Changelog/BootstrapperTest.php +++ b/tests/Changelog/BootstrapperTest.php @@ -69,7 +69,7 @@ public function generate(string $workingDirectory): string } }; - $result = new Bootstrapper($this->filesystem, $historyGenerator) + $result = (new Bootstrapper($this->filesystem, $historyGenerator)) ->bootstrap($workingDirectory); self::assertTrue($result->configCreated); From 28fefc03f9d3aa41144c7c775a1dbe04332f2087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 21:22:01 -0300 Subject: [PATCH 05/11] fix(tests): instantiate Bootstrapper in a single expression for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- tests/Changelog/BootstrapperTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Changelog/BootstrapperTest.php b/tests/Changelog/BootstrapperTest.php index d22f18d..ee29b23 100644 --- a/tests/Changelog/BootstrapperTest.php +++ b/tests/Changelog/BootstrapperTest.php @@ -93,7 +93,7 @@ public function bootstrapWillRestoreMissingUnreleasedSection(): void ); $this->filesystem->dumpFile($workingDirectory . '/.keep-a-changelog.ini', "[defaults]\n"); - $result = new Bootstrapper($this->filesystem) + $result = (new Bootstrapper($this->filesystem)) ->bootstrap($workingDirectory); self::assertFalse($result->configCreated); @@ -116,7 +116,7 @@ public function bootstrapWillRestoreMissingUnreleasedSectionForExistingCustomInt ); $this->filesystem->dumpFile($workingDirectory . '/.keep-a-changelog.ini', "[defaults]\n"); - $result = new Bootstrapper($this->filesystem) + $result = (new Bootstrapper($this->filesystem)) ->bootstrap($workingDirectory); self::assertFalse($result->configCreated); From f6ac6d5cff4cf867e45e697d9ff20fb5086c50e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 22:03:07 -0300 Subject: [PATCH 06/11] refactor: improve docblocks for changelog classes and interfaces & add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- src/Changelog/BootstrapResult.php | 8 +- src/Changelog/Bootstrapper.php | 34 ++-- src/Changelog/BootstrapperInterface.php | 7 + src/Changelog/CommitClassifier.php | 12 +- src/Changelog/CommitClassifierInterface.php | 25 ++- src/Changelog/GitProcessRunner.php | 8 +- src/Changelog/GitProcessRunnerInterface.php | 15 +- src/Changelog/GitReleaseCollector.php | 24 ++- .../GitReleaseCollectorInterface.php | 13 +- src/Changelog/HistoryGenerator.php | 13 +- src/Changelog/HistoryGeneratorInterface.php | 14 +- .../KeepAChangelogConfigRenderer.php | 4 +- src/Changelog/MarkdownRenderer.php | 16 +- src/Changelog/UnreleasedEntryChecker.php | 18 ++- .../UnreleasedEntryCheckerInterface.php | 15 +- tests/Changelog/BootstrapResultTest.php | 54 +++++++ tests/Changelog/BootstrapperTest.php | 144 ++++++++++------- tests/Changelog/GitProcessRunnerTest.php | 61 +++++++ .../KeepAChangelogConfigRendererTest.php | 54 +++++++ tests/Changelog/MarkdownRendererTest.php | 152 ++++++++++++++++++ 20 files changed, 566 insertions(+), 125 deletions(-) create mode 100644 tests/Changelog/BootstrapResultTest.php create mode 100644 tests/Changelog/GitProcessRunnerTest.php create mode 100644 tests/Changelog/KeepAChangelogConfigRendererTest.php create mode 100644 tests/Changelog/MarkdownRendererTest.php diff --git a/src/Changelog/BootstrapResult.php b/src/Changelog/BootstrapResult.php index 54787aa..781846f 100644 --- a/src/Changelog/BootstrapResult.php +++ b/src/Changelog/BootstrapResult.php @@ -24,9 +24,11 @@ final readonly class BootstrapResult { /** - * @param bool $configCreated - * @param bool $changelogCreated - * @param bool $unreleasedCreated + * Creates a new instance of `BootstrapResult`. + * + * @param bool $configCreated indicates whether the configuration file was created during bootstrap + * @param bool $changelogCreated Indicates whether the changelog file was created during bootstrap + * @param bool $unreleasedCreated Indicates whether the unreleased changelog file was created during bootstrap */ public function __construct( public bool $configCreated, diff --git a/src/Changelog/Bootstrapper.php b/src/Changelog/Bootstrapper.php index c460fdb..6f7f9bb 100644 --- a/src/Changelog/Bootstrapper.php +++ b/src/Changelog/Bootstrapper.php @@ -21,7 +21,6 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; -use function Safe\file_get_contents; use function rtrim; use function str_contains; use function str_replace; @@ -34,24 +33,27 @@ final readonly class Bootstrapper implements BootstrapperInterface { /** - * @param Filesystem|null $filesystem - * @param HistoryGeneratorInterface|null $historyGenerator - * @param KeepAChangelogConfigRenderer|null $configRenderer + * Initializes the `Bootstrapper` with optional dependencies. + * + * @param Filesystem $filesystem filesystem instance for file operations, allowing for easier testing and potential customization + * @param HistoryGeneratorInterface $historyGenerator history generator instance for generating changelog history + * @param KeepAChangelogConfigRenderer $configRenderer config renderer instance for rendering keep-a-changelog configuration */ public function __construct( - private ?Filesystem $filesystem = null, - private ?HistoryGeneratorInterface $historyGenerator = null, - private ?KeepAChangelogConfigRenderer $configRenderer = null, + private Filesystem $filesystem = new Filesystem(), + private HistoryGeneratorInterface $historyGenerator = new HistoryGenerator(), + private KeepAChangelogConfigRenderer $configRenderer = new KeepAChangelogConfigRenderer(), ) {} /** + * Bootstraps changelog automation assets in the given working directory. + * * @param string $workingDirectory * * @return BootstrapResult */ public function bootstrap(string $workingDirectory): BootstrapResult { - $filesystem = $this->filesystem ?? new Filesystem(); $configPath = Path::join($workingDirectory, '.keep-a-changelog.ini'); $changelogPath = Path::join($workingDirectory, 'CHANGELOG.md'); @@ -59,26 +61,22 @@ public function bootstrap(string $workingDirectory): BootstrapResult $changelogCreated = false; $unreleasedCreated = false; - if (! $filesystem->exists($configPath)) { - $filesystem->dumpFile($configPath, ($this->configRenderer ?? new KeepAChangelogConfigRenderer())->render()); + if (! $this->filesystem->exists($configPath)) { + $this->filesystem->dumpFile($configPath, $this->configRenderer->render()); $configCreated = true; } - if (! $filesystem->exists($changelogPath)) { - $filesystem->dumpFile( - $changelogPath, - ($this->historyGenerator ?? new HistoryGenerator()) - ->generate($workingDirectory), - ); + if (! $this->filesystem->exists($changelogPath)) { + $this->filesystem->dumpFile($changelogPath, $this->historyGenerator->generate($workingDirectory)); $changelogCreated = true; return new BootstrapResult($configCreated, $changelogCreated, $unreleasedCreated); } - $contents = file_get_contents($changelogPath); + $contents = $this->filesystem->readFile($changelogPath); if (! str_contains($contents, '## Unreleased - ')) { - $filesystem->dumpFile($changelogPath, $this->prependUnreleasedSection($contents)); + $this->filesystem->dumpFile($changelogPath, $this->prependUnreleasedSection($contents)); $unreleasedCreated = true; } diff --git a/src/Changelog/BootstrapperInterface.php b/src/Changelog/BootstrapperInterface.php index 6eb9dfd..3ba7e77 100644 --- a/src/Changelog/BootstrapperInterface.php +++ b/src/Changelog/BootstrapperInterface.php @@ -20,10 +20,17 @@ /** * Bootstraps repository-local changelog automation artifacts. + * + * The BootstrapperInterface defines a contract for bootstrapping changelog automation assets in a given working directory. + * Implementations of this interface are MUST setup necessary files, configurations, or other resources required to enable + * changelog automation in a repository. The bootstrap method takes a working directory as input and returns a BootstrapResult + * indicating the outcome of the bootstrapping process. */ interface BootstrapperInterface { /** + * Bootstraps changelog automation assets in the given working directory. + * * @param string $workingDirectory * * @return BootstrapResult diff --git a/src/Changelog/CommitClassifier.php b/src/Changelog/CommitClassifier.php index a826ff3..46198b7 100644 --- a/src/Changelog/CommitClassifier.php +++ b/src/Changelog/CommitClassifier.php @@ -30,9 +30,11 @@ final readonly class CommitClassifier implements CommitClassifierInterface { /** - * @param string $subject + * Classifies a commit subject into a changelog section based on conventional prefixes and keywords. * - * @return string + * @param string $subject commit subject to classify + * + * @return string Changelog section name (e.g., "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security", or "Uncategorized"). */ public function classify(string $subject): string { @@ -64,9 +66,11 @@ public function classify(string $subject): string } /** - * @param string $subject + * Normalizes a commit subject by stripping conventional prefixes, tags, and extra whitespace, while preserving the core message. + * + * @param string $subject commit subject to normalize * - * @return string + * @return string normalized commit subject */ public function normalize(string $subject): string { diff --git a/src/Changelog/CommitClassifierInterface.php b/src/Changelog/CommitClassifierInterface.php index e899e85..dffdec0 100644 --- a/src/Changelog/CommitClassifierInterface.php +++ b/src/Changelog/CommitClassifierInterface.php @@ -20,20 +20,37 @@ /** * Maps raw commit subjects to Keep a Changelog sections. + * + * The CommitClassifierInterface defines a contract for classifying commit subjects into specific changelog + * sections based on conventional prefixes and keywords. + * + * Implementations of this interface MUST analyze commit subjects and determine the appropriate + * changelog section (e.g., "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security", or "Uncategorized") based + * on recognized patterns such as "fix:", "feat:", "docs:", "chore:", and security-related keywords. */ interface CommitClassifierInterface { /** - * @param string $subject + * Classifies a commit subject into a changelog section based on conventional prefixes and keywords. + * + * The classification logic SHOULD recognize common patterns such as "fix:", "feat:", "docs:", "chore:", + * and security-related keywords, while also allowing for free-form subjects to be categorized under a default section. * - * @return string + * @param string $subject commit subject to classify + * + * @return string Changelog section name (e.g., "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security", or "Uncategorized"). */ public function classify(string $subject): string; /** - * @param string $subject + * Normalizes a commit subject by stripping conventional prefixes, tags, and extra whitespace, while preserving the core message. + * + * The normalization process SHOULD remove any conventional commit type indicators (e.g., "fix:", "feat:", "docs:") + * and scope annotations (e.g., "(api)"), + * + * @param string $subject commit subject to normalize * - * @return string + * @return string normalized commit subject */ public function normalize(string $subject): string; } diff --git a/src/Changelog/GitProcessRunner.php b/src/Changelog/GitProcessRunner.php index 948ee6d..02f27b7 100644 --- a/src/Changelog/GitProcessRunner.php +++ b/src/Changelog/GitProcessRunner.php @@ -28,10 +28,12 @@ final readonly class GitProcessRunner implements GitProcessRunnerInterface { /** - * @param list $command - * @param string $workingDirectory + * Executes a git command in the specified working directory and returns the trimmed output. * - * @return string + * @param list $command Git command to execute (e.g., ['git', 'log', '--oneline']). + * @param string $workingDirectory Directory in which to execute the command (e.g., repository root). + * + * @return string trimmed output from the executed command */ public function run(array $command, string $workingDirectory): string { diff --git a/src/Changelog/GitProcessRunnerInterface.php b/src/Changelog/GitProcessRunnerInterface.php index b179f7d..52377bb 100644 --- a/src/Changelog/GitProcessRunnerInterface.php +++ b/src/Changelog/GitProcessRunnerInterface.php @@ -20,16 +20,25 @@ /** * Executes git-aware shell commands for changelog automation services. + * + * The GitProcessRunnerInterface defines a contract for executing git-related commands in the context of changelog automation. + * Implementations of this interface MUST run specified git commands in a given working directory and return the trimmed output. + * The run method takes a list of command arguments and a working directory as input, and it returns the output from the executed command, + * allowing changelog automation services to interact with git repositories effectively. */ interface GitProcessRunnerInterface { /** * Runs a command in the provided working directory and returns stdout. * - * @param list $command - * @param string $workingDirectory + * The method SHOULD execute the given command and return the trimmed output. + * The implementation MUST handle any necessary process execution and error handling, + * ensuring that the command is executed in the context of the specified working directory. + * + * @param list $command Git command to execute (e.g., ['git', 'log', '--oneline']). + * @param string $workingDirectory Directory in which to execute the command (e.g., repository root). * - * @return string + * @return string trimmed output from the executed command */ public function run(array $command, string $workingDirectory): string; } diff --git a/src/Changelog/GitReleaseCollector.php b/src/Changelog/GitReleaseCollector.php index 0311cac..fe39a6b 100644 --- a/src/Changelog/GitReleaseCollector.php +++ b/src/Changelog/GitReleaseCollector.php @@ -32,16 +32,20 @@ final readonly class GitReleaseCollector implements GitReleaseCollectorInterface { /** - * @param GitProcessRunnerInterface $gitProcessRunner + * Initializes the GitReleaseCollector with a GitProcessRunner for executing git commands. + * + * @param GitProcessRunnerInterface $gitProcessRunner git process runner for executing git commands */ public function __construct( private GitProcessRunnerInterface $gitProcessRunner = new GitProcessRunner() ) {} /** - * @param string $workingDirectory + * Collects release information from git tags in the specified working directory. + * + * @param string $workingDirectory Directory in which to execute git commands (e.g., repository root). * - * @return list}> + * @return list}> list of releases with version, tag, date, and associated commit subjects */ public function collect(string $workingDirectory): array { @@ -90,10 +94,12 @@ public function collect(string $workingDirectory): array } /** - * @param string $workingDirectory - * @param string $range + * Collects commit subjects for a given git range in the specified working directory. * - * @return list + * @param string $workingDirectory Directory in which to execute git commands (e.g., repository root). + * @param string $range Git range to collect commits from (e.g., 'v1.0.0..v1.1.0' or 'v1.0.0'). + * + * @return list list of commit subjects for the specified range, excluding merges and ignored subjects */ private function collectCommitSubjects(string $workingDirectory, string $range): array { @@ -116,9 +122,11 @@ private function collectCommitSubjects(string $workingDirectory, string $range): } /** - * @param string $subject + * Determines whether a commit subject should be ignored based on common patterns (e.g., merge commits, wiki updates). + * + * @param string $subject commit subject to evaluate for ignoring * - * @return bool + * @return bool True if the subject should be ignored (e.g., empty, merge commits, wiki updates); false otherwise. */ private function shouldIgnore(string $subject): bool { diff --git a/src/Changelog/GitReleaseCollectorInterface.php b/src/Changelog/GitReleaseCollectorInterface.php index cf4282f..8ff37af 100644 --- a/src/Changelog/GitReleaseCollectorInterface.php +++ b/src/Changelog/GitReleaseCollectorInterface.php @@ -20,13 +20,22 @@ /** * Discovers released tags and the commit subjects they contain. + * + * The GitReleaseCollectorInterface defines a contract for collecting release information from git tags in a specified working directory. + * Implementations of this interface are responsible for executing git commands to read tags and their associated commit subjects, building + * a structured list of releases that includes version, tag name, creation date, and commit */ interface GitReleaseCollectorInterface { /** - * @param string $workingDirectory + * Collects release information from git tags in the specified working directory. + * + * The method SHOULD read git tags and their associated commit subjects to build a structured list of releases. + * Each release entry MUST include the version, tag name, creation date, and a list of commit subjects that are part of that release. + * + * @param string $workingDirectory Directory in which to execute git commands (e.g., repository root). * - * @return list}> + * @return list}> list of releases with version, tag, date, and associated commit subjects */ public function collect(string $workingDirectory): array; } diff --git a/src/Changelog/HistoryGenerator.php b/src/Changelog/HistoryGenerator.php index 0be9aa9..42101b0 100644 --- a/src/Changelog/HistoryGenerator.php +++ b/src/Changelog/HistoryGenerator.php @@ -26,14 +26,16 @@ final readonly class HistoryGenerator implements HistoryGeneratorInterface { /** - * @param GitReleaseCollectorInterface $gitReleaseCollector - * @param CommitClassifierInterface $commitClassifier - * @param MarkdownRenderer|null $markdownRenderer + * Initializes the `HistoryGenerator` with optional dependencies. + * + * @param GitReleaseCollectorInterface $gitReleaseCollector git release collector instance for collecting release metadata and commit subjects + * @param CommitClassifierInterface $commitClassifier commit classifier instance for classifying and normalizing commit subjects into changelog sections + * @param MarkdownRenderer $markdownRenderer markdown renderer instance for rendering the final changelog markdown from structured release and commit data */ public function __construct( private GitReleaseCollectorInterface $gitReleaseCollector = new GitReleaseCollector(), private CommitClassifierInterface $commitClassifier = new CommitClassifier(), - private ?MarkdownRenderer $markdownRenderer = null, + private MarkdownRenderer $markdownRenderer = new MarkdownRenderer(), ) {} /** @@ -65,7 +67,6 @@ public function generate(string $workingDirectory): string ]; } - return ($this->markdownRenderer ?? new MarkdownRenderer()) - ->render($releases); + return $this->markdownRenderer->render($releases); } } diff --git a/src/Changelog/HistoryGeneratorInterface.php b/src/Changelog/HistoryGeneratorInterface.php index 6da6c20..e762228 100644 --- a/src/Changelog/HistoryGeneratorInterface.php +++ b/src/Changelog/HistoryGeneratorInterface.php @@ -20,13 +20,23 @@ /** * Builds initial changelog markdown from repository release history. + * + * The HistoryGeneratorInterface defines a contract for generating changelog markdown based on the release history of a repository. + * Implementations of this interface MUST collect release metadata and commit subjects, classify and normalize commit subjects into changelog sections, + * and render the final changelog markdown. */ interface HistoryGeneratorInterface { /** - * @param string $workingDirectory + * Generates changelog markdown from the release history of the repository in the given working directory. + * + * The generate method SHOULD collect release metadata and commit subjects using a GitReleaseCollectorInterface implementation, + * classify and normalize commit subjects into changelog sections using a CommitClassifierInterface implementation, + * and render the final changelog markdown using a MarkdownRenderer implementation. The method MUST return the generated changelog markdown as a string. + * + * @param string $workingDirectory Directory in which to generate the changelog (e.g., repository root). * - * @return string + * @return string Generated changelog markdown based on the repository's release history */ public function generate(string $workingDirectory): string; } diff --git a/src/Changelog/KeepAChangelogConfigRenderer.php b/src/Changelog/KeepAChangelogConfigRenderer.php index 622bbf3..f556221 100644 --- a/src/Changelog/KeepAChangelogConfigRenderer.php +++ b/src/Changelog/KeepAChangelogConfigRenderer.php @@ -26,7 +26,9 @@ final readonly class KeepAChangelogConfigRenderer { /** - * @return string + * Renders the content of the keep-a-changelog configuration file. + * + * @return string the content of the keep-a-changelog configuration file as a string */ public function render(): string { diff --git a/src/Changelog/MarkdownRenderer.php b/src/Changelog/MarkdownRenderer.php index 9ceefaf..93da5f8 100644 --- a/src/Changelog/MarkdownRenderer.php +++ b/src/Changelog/MarkdownRenderer.php @@ -32,9 +32,11 @@ private const array SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']; /** - * @param list>}> $releases + * Renders the changelog markdown content. * - * @return string + * @param list>}> $releases list of releases with their version, date, and entries + * + * @return string the generated changelog markdown content */ public function render(array $releases): string { @@ -58,11 +60,13 @@ public function render(array $releases): string } /** - * @param string $version - * @param string $date - * @param array> $entries + * Renders a section of the changelog for a specific release. + * + * @param string $version the version of the release + * @param string $date the release date + * @param array> $entries the entries for the release, categorized by section * - * @return list + * @return list the rendered lines for the release section */ private function renderSection(string $version, string $date, array $entries): array { diff --git a/src/Changelog/UnreleasedEntryChecker.php b/src/Changelog/UnreleasedEntryChecker.php index f4e4ecf..13f2a5f 100644 --- a/src/Changelog/UnreleasedEntryChecker.php +++ b/src/Changelog/UnreleasedEntryChecker.php @@ -36,17 +36,21 @@ final readonly class UnreleasedEntryChecker implements UnreleasedEntryCheckerInterface { /** - * @param GitProcessRunnerInterface $gitProcessRunner + * Constructs a new UnreleasedEntryChecker. + * + * @param GitProcessRunnerInterface $gitProcessRunner the Git process runner */ public function __construct( private GitProcessRunnerInterface $gitProcessRunner = new GitProcessRunner() ) {} /** - * @param string $workingDirectory - * @param string|null $againstReference + * Checks if there are pending unreleased entries in the changelog compared to a given reference. + * + * @param string $workingDirectory the working directory of the repository + * @param string|null $againstReference The reference to compare against (e.g., a branch or commit hash). * - * @return bool + * @return bool true if there are pending unreleased entries, false otherwise */ public function hasPendingChanges(string $workingDirectory, ?string $againstReference = null): bool { @@ -82,9 +86,11 @@ public function hasPendingChanges(string $workingDirectory, ?string $againstRefe } /** - * @param string $contents + * Extracts unreleased entries from the given changelog content. + * + * @param string $contents the changelog content * - * @return list + * @return list the list of unreleased entries */ private function extractEntries(string $contents): array { diff --git a/src/Changelog/UnreleasedEntryCheckerInterface.php b/src/Changelog/UnreleasedEntryCheckerInterface.php index 963bf09..53a5e77 100644 --- a/src/Changelog/UnreleasedEntryCheckerInterface.php +++ b/src/Changelog/UnreleasedEntryCheckerInterface.php @@ -20,14 +20,23 @@ /** * Verifies that the changelog contains meaningful unreleased changes. + * + * This is used to prevent merging changes that have not been documented in the changelog. + * It compares the unreleased entries in the changelog against the current branch or a specified reference (e.g., a base branch or commit hash). */ interface UnreleasedEntryCheckerInterface { /** - * @param string $workingDirectory - * @param string|null $againstReference + * Checks if there are pending unreleased entries in the changelog compared to a given reference. + * + * This method MUST read the unreleased section of the changelog and compare it against the changes in the current branch or a specified reference. + * If there are entries in the unreleased section that are not present in the reference, it indicates that there are pending changes that have not been released yet. + * The method MUST return true if there are pending unreleased entries, and false otherwise. + * + * @param string $workingDirectory the working directory of the repository + * @param string|null $againstReference The reference to compare against (e.g., a branch or commit hash). * - * @return bool + * @return bool true if there are pending unreleased entries, false otherwise */ public function hasPendingChanges(string $workingDirectory, ?string $againstReference = null): bool; } diff --git a/tests/Changelog/BootstrapResultTest.php b/tests/Changelog/BootstrapResultTest.php new file mode 100644 index 0000000..4cdc768 --- /dev/null +++ b/tests/Changelog/BootstrapResultTest.php @@ -0,0 +1,54 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\BootstrapResult; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(BootstrapResult::class)] +final class BootstrapResultTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function createWillReturnInstanceWithAllPropertiesSet(): void + { + $result = new BootstrapResult(true, true, false); + + self::assertTrue($result->configCreated); + self::assertTrue($result->changelogCreated); + self::assertFalse($result->unreleasedCreated); + } + + /** + * @return void + */ + #[Test] + public function createWillAllowMixedBooleanValues(): void + { + $result = new BootstrapResult(false, true, true); + + self::assertFalse($result->configCreated); + self::assertTrue($result->changelogCreated); + self::assertTrue($result->unreleasedCreated); + } +} diff --git a/tests/Changelog/BootstrapperTest.php b/tests/Changelog/BootstrapperTest.php index ee29b23..f4a01e7 100644 --- a/tests/Changelog/BootstrapperTest.php +++ b/tests/Changelog/BootstrapperTest.php @@ -26,19 +26,27 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Filesystem\Filesystem; -use function Safe\file_get_contents; -use function Safe\mkdir; -use function uniqid; -use function sys_get_temp_dir; - #[CoversClass(Bootstrapper::class)] #[UsesClass(BootstrapResult::class)] #[UsesClass(KeepAChangelogConfigRenderer::class)] final class BootstrapperTest extends TestCase { - private Filesystem $filesystem; + use ProphecyTrait; + + private ObjectProphecy $filesystem; + + private ObjectProphecy $historyGenerator; + + private ObjectProphecy $configRenderer; + + private string $workingDirectory; + + private Bootstrapper $bootstrapper; /** * @return void @@ -47,7 +55,52 @@ protected function setUp(): void { parent::setUp(); - $this->filesystem = new Filesystem(); + $this->filesystem = $this->prophesize(Filesystem::class); + $this->historyGenerator = $this->prophesize(HistoryGeneratorInterface::class); + $this->configRenderer = $this->prophesize(KeepAChangelogConfigRenderer::class); + $this->workingDirectory = '/tmp/fake-dir'; + + $this->bootstrapper = new Bootstrapper( + $this->filesystem->reveal(), + $this->historyGenerator->reveal(), + $this->configRenderer->reveal() + ); + } + + /** + * @return void + */ + private function givenFilesExist(): void + { + $this->filesystem->exists('/tmp/fake-dir/.keep-a-changelog.ini') + ->willReturn(true) + ->shouldBeCalled(); + $this->filesystem->exists('/tmp/fake-dir/CHANGELOG.md') + ->willReturn(true) + ->shouldBeCalled(); + } + + /** + * @return void + */ + private function givenFilesDoNotExist(): void + { + $this->filesystem->exists('/tmp/fake-dir/.keep-a-changelog.ini') + ->willReturn(false) + ->shouldBeCalled(); + $this->filesystem->exists('/tmp/fake-dir/CHANGELOG.md') + ->willReturn(false) + ->shouldBeCalled(); + $this->configRenderer->render() + ->willReturn('[defaults]') + ->shouldBeCalled(); + $this->filesystem->dumpFile('/tmp/fake-dir/.keep-a-changelog.ini', '[defaults]') + ->shouldBeCalled(); + $this->historyGenerator->generate('/tmp/fake-dir') + ->willReturn('# Changelog') + ->shouldBeCalled(); + $this->filesystem->dumpFile('/tmp/fake-dir/CHANGELOG.md', '# Changelog') + ->shouldBeCalled(); } /** @@ -56,27 +109,13 @@ protected function setUp(): void #[Test] public function bootstrapWillCreateMissingConfigAndChangelogFiles(): void { - $workingDirectory = $this->createTemporaryDirectory(); - $historyGenerator = new class implements HistoryGeneratorInterface { - /** - * @param string $workingDirectory - * - * @return string - */ - public function generate(string $workingDirectory): string - { - return "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n## Unreleased - TBD\n\n### Added\n\n- Nothing.\n"; - } - }; - - $result = (new Bootstrapper($this->filesystem, $historyGenerator)) - ->bootstrap($workingDirectory); + $this->givenFilesDoNotExist(); + + $result = $this->bootstrapper->bootstrap($this->workingDirectory); self::assertTrue($result->configCreated); self::assertTrue($result->changelogCreated); self::assertFalse($result->unreleasedCreated); - self::assertFileExists($workingDirectory . '/.keep-a-changelog.ini'); - self::assertFileExists($workingDirectory . '/CHANGELOG.md'); } /** @@ -85,21 +124,22 @@ public function generate(string $workingDirectory): string #[Test] public function bootstrapWillRestoreMissingUnreleasedSection(): void { - $workingDirectory = $this->createTemporaryDirectory(); - + $this->givenFilesExist(); + $this->filesystem->readFile('/tmp/fake-dir/CHANGELOG.md') + ->willReturn( + "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n" + ) + ->shouldBeCalled(); $this->filesystem->dumpFile( - $workingDirectory . '/CHANGELOG.md', - "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n", - ); - $this->filesystem->dumpFile($workingDirectory . '/.keep-a-changelog.ini', "[defaults]\n"); + '/tmp/fake-dir/CHANGELOG.md', + Argument::that(fn(string $content): bool => str_contains($content, '## Unreleased - TBD')) + )->shouldBeCalled(); - $result = (new Bootstrapper($this->filesystem)) - ->bootstrap($workingDirectory); + $result = $this->bootstrapper->bootstrap($this->workingDirectory); self::assertFalse($result->configCreated); self::assertFalse($result->changelogCreated); self::assertTrue($result->unreleasedCreated); - self::assertStringContainsString('## Unreleased - TBD', file_get_contents($workingDirectory . '/CHANGELOG.md')); } /** @@ -108,34 +148,26 @@ public function bootstrapWillRestoreMissingUnreleasedSection(): void #[Test] public function bootstrapWillRestoreMissingUnreleasedSectionForExistingCustomIntro(): void { - $workingDirectory = $this->createTemporaryDirectory(); - + $this->givenFilesExist(); + $this->filesystem->readFile('/tmp/fake-dir/CHANGELOG.md') + ->willReturn( + "# Changelog\n\nProject-specific introduction.\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n" + ) + ->shouldBeCalled(); $this->filesystem->dumpFile( - $workingDirectory . '/CHANGELOG.md', - "# Changelog\n\nProject-specific introduction.\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n", - ); - $this->filesystem->dumpFile($workingDirectory . '/.keep-a-changelog.ini', "[defaults]\n"); + '/tmp/fake-dir/CHANGELOG.md', + Argument::that( + fn(string $content): bool => str_contains( + $content, + "Project-specific introduction.\n\n## Unreleased - TBD\n\n### Added" + ) + ) + )->shouldBeCalled(); - $result = (new Bootstrapper($this->filesystem)) - ->bootstrap($workingDirectory); + $result = $this->bootstrapper->bootstrap($this->workingDirectory); self::assertFalse($result->configCreated); self::assertFalse($result->changelogCreated); self::assertTrue($result->unreleasedCreated); - self::assertStringContainsString( - "Project-specific introduction.\n\n## Unreleased - TBD\n\n### Added", - file_get_contents($workingDirectory . '/CHANGELOG.md'), - ); - } - - /** - * @return string - */ - private function createTemporaryDirectory(): string - { - $directory = sys_get_temp_dir() . '/' . uniqid('dev-tools-changelog-', true); - mkdir($directory); - - return $directory; } } diff --git a/tests/Changelog/GitProcessRunnerTest.php b/tests/Changelog/GitProcessRunnerTest.php new file mode 100644 index 0000000..7217c6f --- /dev/null +++ b/tests/Changelog/GitProcessRunnerTest.php @@ -0,0 +1,61 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\GitProcessRunner; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(GitProcessRunner::class)] +final class GitProcessRunnerTest extends TestCase +{ + private GitProcessRunner $runner; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->runner = new GitProcessRunner(); + } + + /** + * @return void + */ + #[Test] + public function runWillExecuteGitCommandAndReturnTrimmedOutput(): void + { + $output = $this->runner->run(['git', 'version'], __DIR__); + + self::assertStringStartsWith('git version', $output); + } + + /** + * @return void + */ + #[Test] + public function runWillTrimWhitespaceFromOutput(): void + { + $output = $this->runner->run(['git', 'rev-parse', '--short', 'HEAD'], __DIR__); + + self::assertSame(7, \strlen($output)); + } +} diff --git a/tests/Changelog/KeepAChangelogConfigRendererTest.php b/tests/Changelog/KeepAChangelogConfigRendererTest.php new file mode 100644 index 0000000..2114e35 --- /dev/null +++ b/tests/Changelog/KeepAChangelogConfigRendererTest.php @@ -0,0 +1,54 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\KeepAChangelogConfigRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(KeepAChangelogConfigRenderer::class)] +final class KeepAChangelogConfigRendererTest extends TestCase +{ + private KeepAChangelogConfigRenderer $renderer; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->renderer = new KeepAChangelogConfigRenderer(); + } + + /** + * @return void + */ + #[Test] + public function renderWillReturnKeepAChangelogConfiguration(): void + { + $output = $this->renderer->render(); + + self::assertStringContainsString('[defaults]', $output); + self::assertStringContainsString('changelog_file = CHANGELOG.md', $output); + self::assertStringContainsString('provider = github', $output); + self::assertStringContainsString('remote = origin', $output); + self::assertStringContainsString('[providers]', $output); + } +} diff --git a/tests/Changelog/MarkdownRendererTest.php b/tests/Changelog/MarkdownRendererTest.php new file mode 100644 index 0000000..6d8d7b2 --- /dev/null +++ b/tests/Changelog/MarkdownRendererTest.php @@ -0,0 +1,152 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\MarkdownRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(MarkdownRenderer::class)] +final class MarkdownRendererTest extends TestCase +{ + private MarkdownRenderer $renderer; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->renderer = new MarkdownRenderer(); + } + + /** + * @return void + */ + #[Test] + public function renderWillGenerateChangelogWithHeader(): void + { + $output = $this->renderer->render([]); + + self::assertStringStartsWith('# Changelog', $output); + self::assertStringContainsString( + 'All notable changes to this project will be documented in this file', + $output + ); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeUnreleasedSection(): void + { + $output = $this->renderer->render([]); + + self::assertStringContainsString('## Unreleased - TBD', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeAllSectionTypes(): void + { + $output = $this->renderer->render([]); + + self::assertStringContainsString('### Added', $output); + self::assertStringContainsString('### Changed', $output); + self::assertStringContainsString('### Deprecated', $output); + self::assertStringContainsString('### Removed', $output); + self::assertStringContainsString('### Fixed', $output); + self::assertStringContainsString('### Security', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeReleaseDataInReverseChronologicalOrder(): void + { + $releases = [ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [ + 'Added' => ['Feature A'], + ], + ], + [ + 'version' => '0.9.0', + 'date' => '2026-03-01', + 'entries' => [ + 'Added' => ['Feature B'], + ], + ], + ]; + + $output = $this->renderer->render($releases); + + self::assertStringContainsString('## 0.9.0 - 2026-03-01', $output); + self::assertStringContainsString('## 1.0.0 - 2026-04-01', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeEntriesForSections(): void + { + $releases = [ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [ + 'Added' => ['New feature'], + 'Fixed' => ['Bug fix'], + ], + ], + ]; + + $output = $this->renderer->render($releases); + + self::assertStringContainsString('- New feature', $output); + self::assertStringContainsString('- Bug fix', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillShowNothingWhenNoEntriesForReleasedVersion(): void + { + $releases = [ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [], + ], + ]; + + $output = $this->renderer->render($releases); + + self::assertStringContainsString('- Nothing.', $output); + } +} From df4d16e55d465acb88867c7b170eec0702295b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 22:39:07 -0300 Subject: [PATCH 07/11] feat: add changelog generator and related documentation for CHANGELOG.md management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- .agents/skills/changelog-generator/SKILL.md | 118 ++++++++ .../changelog-generator/agents/openai.yaml | 4 + .../references/change-categories.md | 74 +++++ .../references/description-patterns.md | 64 +++++ .../references/keep-a-changelog-format.md | 51 ++++ CHANGELOG.md | 259 +++++------------- 6 files changed, 373 insertions(+), 197 deletions(-) create mode 100644 .agents/skills/changelog-generator/SKILL.md create mode 100644 .agents/skills/changelog-generator/agents/openai.yaml create mode 100644 .agents/skills/changelog-generator/references/change-categories.md create mode 100644 .agents/skills/changelog-generator/references/description-patterns.md create mode 100644 .agents/skills/changelog-generator/references/keep-a-changelog-format.md diff --git a/.agents/skills/changelog-generator/SKILL.md b/.agents/skills/changelog-generator/SKILL.md new file mode 100644 index 0000000..c95fc5e --- /dev/null +++ b/.agents/skills/changelog-generator/SKILL.md @@ -0,0 +1,118 @@ +--- +name: changelog-generator +description: Generate and maintain CHANGELOG.md following Keep a Changelog format with human-readable descriptions. Use when: (1) Creating initial changelog from git tags, (2) Updating changelog for new releases, (3) Generating unreleased section for pull requests. Rule: NEVER use commit messages as source of truth - analyze code diffs instead. +--- + +# Changelog Generator + +Generates and maintains CHANGELOG.md following the Keep a Changelog format with clear, specific, and self-sufficient descriptions. + +## Dependencies + +- `phly/keep-a-changelog` - Installed in project +- Git - For analyzing code changes +- Filesystem - For reading/writing CHANGELOG.md + +## Key Commands + +```bash +vendor/bin/changelog # Main CLI +vendor/bin/changelog add:entry # Add entry to version +vendor/bin/changelog release # Create release +``` + +## Execution Pipeline (Deterministic) + +### Stage 1: Initial State + +1. Check if CHANGELOG.md exists and has content: + ```bash + ls -la CHANGELOG.md 2>/dev/null || echo "NO_FILE" + ``` + +### Stage 2: Version Discovery + +1. List all tags sorted semantically: + ```bash + git tag --sort=-version:refname + ``` + +2. Identify: + - Last documented version in CHANGELOG + - Tags not yet documented + +### Stage 3: Historical Content Generation + +**Case A: No CHANGELOG or Empty** + +For each tag (ascending order): +1. Calculate diff between current tag and previous tag (or first commit for initial version) +2. Analyze code diff to infer changes (NOT commit messages) +3. Group changes by type (Added, Changed, Fixed, Removed, Deprecated, Security) +4. Insert version section + +**B: Existing CHANGELOG** + +1. Identify last documented version +2. For each subsequent tag: + - Generate diff between versions + - Insert new section in changelog + +### Stage 4: Unreleased Section + +1. Calculate diff between last documented tag and HEAD +2. Generate [Unreleased] section with current changes + +## Change Classification (Inferred from Diff) + +Analyze actual code changes, NOT commit messages: + +| Pattern | Category | +|---------|----------| +| New files, new classes, new methods | Added | +| Behavior changes, refactors, signature changes | Changed | +| Bug fixes, validation fixes | Fixed | +| Deleted classes, removed methods | Removed | +| @deprecated markers | Deprecated | +| Security patches | Security | + +## Quality Rules + +- **SHORT**: One line per change +- **SPECIFIC**: Include class/method names +- **SELF-SUFFICIENT**: Understand without reading code +- **FUNCTIONAL**: Describe impact, not implementation + +Good: "Added `Bootstrapper::bootstrap()` to create CHANGELOG.md when missing" +Bad: "Add bootstrap command" + +## Integration with keep-a-changelog + +Use CLI commands when possible: + +```bash +# Add unreleased entry +vendor/bin/changelog add:entry --unreleased --type=added "Description" + +# Add release entry +vendor/bin/changelog add:entry 1.0.0 --type=added "Description" + +# Create release +vendor/bin/changelog release 1.0.0 --date="2026-04-11" +``` + +Edit CHANGELOG.md directly if CLI insufficient. + +## Verification + +Valid changelog MUST have: +- All sections: Added, Changed, Deprecated, Removed, Fixed, Security +- No "Nothing." placeholders (unless truly empty) +- Reverse chronological order (newest first) +- [Unreleased] at top when applicable + +## Reference Files + +- [references/keep-a-changelog-format.md](references/keep-a-changelog-format.md) - Format spec +- [references/change-categories.md](references/change-categories.md) - Classification guide +- [references/description-patterns.md](references/description-patterns.md) - Human-readable patterns \ No newline at end of file diff --git a/.agents/skills/changelog-generator/agents/openai.yaml b/.agents/skills/changelog-generator/agents/openai.yaml new file mode 100644 index 0000000..794c7cf --- /dev/null +++ b/.agents/skills/changelog-generator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fast Forward Changelog Generator" + short_description: "Generate and maintain Keep a Changelog entries" + default_prompt: "Use $changelog-generator to create CHANGELOG.md from git tags" diff --git a/.agents/skills/changelog-generator/references/change-categories.md b/.agents/skills/changelog-generator/references/change-categories.md new file mode 100644 index 0000000..73336d1 --- /dev/null +++ b/.agents/skills/changelog-generator/references/change-categories.md @@ -0,0 +1,74 @@ +# Change Categories + +## Classification by Diff Analysis + +Infer category from code patterns, NOT from commit messages. + +## Added + +**Patterns:** +- New PHP class files +- New methods in existing classes +- New configuration options +- New CLI commands +- New public APIs + +**Examples:** +- `+class Bootstrapper` → "Added `Bootstrapper` class for changelog bootstrapping" +- `+public function render()` → "Added `MarkdownRenderer::render()` method" +- `+->addOption()` → "Added `--output` option to command" + +## Changed + +**Patterns:** +- Modified method signatures +- Changed default values +- Behavior modifications +- Refactors that affect the public API + +**Examples:** +- `function foo($bar)` → `function foo($bar, $baz = null)` → "Changed `foo()` to accept optional `$baz` parameter" +- `return void` → `return string` → "Changed `render()` to return string instead of void" + +## Fixed + +**Patterns:** +- Bug fixes +- Validation improvements +- Edge case handling +- Error handling corrections + +**Examples:** +- Empty input validation, null checks → "Fixed handling of null input in `parse()`" +- Regex fixes → "Fixed validation of version numbers" + +## Removed + +**Patterns:** +- Deleted classes +- Deleted methods +- Deleted configuration options + +**Examples:** +- `-class LegacyParser` → "Removed deprecated `LegacyParser` class" +- `-function oldMethod()` → "Removed deprecated `oldMethod()` method" + +## Deprecated + +**Patterns:** +- @deprecated annotations +- Deprecation notices in code + +**Examples:** +- `@deprecated` → "Deprecated `LegacyParser`, use `MarkdownParser` instead" + +## Security + +**Patterns:** +- Security patches +- Vulnerability fixes +- Input sanitization + +**Examples:** +- XSS fixes → "Fixed XSS vulnerability in user input" +- CSRF protection → "Added CSRF protection to form handling" \ No newline at end of file diff --git a/.agents/skills/changelog-generator/references/description-patterns.md b/.agents/skills/changelog-generator/references/description-patterns.md new file mode 100644 index 0000000..339af1b --- /dev/null +++ b/.agents/skills/changelog-generator/references/description-patterns.md @@ -0,0 +1,64 @@ +# Description Patterns + +## How to Write Human-Readable Descriptions + +Rule: Describe the IMPACT, not the IMPLEMENTATION. + +## Quality Criteria + +| Criterion | Good | Bad | +|-----------|------|-----| +| Specific | "Added `ChangelogCheckCommand` to verifies that the changelog contains pending unreleased notes." | "Added new feature" | +| Short | One line | Paragraph | +| Self-sufficient | "Creates CHANGELOG.md with all current version released" | "Bootstrap support" | +| Actionable | "Added `--filter` option on TestsCommand to be able to filter test pattern classes" | "Improved CLI" | + +## Transformation Examples + +### Bad → Good + +``` +Bad: "feat: add bootstrap" +Good: "Added `Bootstrapper` class to create CHANGELOG.md when missing" + +Bad: "refactor: extract to new class" +Good: "Extracted `CommitClassifier` for improved separation of concerns" + +Bad: "fix: validate unreleased notes" +Good: "Fixed validation of unreleased changelog entries" + +Bad: "chore: update dependencies" +Good: N/A - Skip infrastructure-only changes +``` + +## Class Names Pattern + +Always include class/method names: + +```markdown +- Added `Bootstrapper` to bootstrap changelog assets +- Added `MarkdownRenderer::render()` for generating output +- Changed `Config::load()` to accept optional path parameter +- Fixed `Parser::parse()` handling of empty input +``` + +## API Changes Pattern + +```markdown +- Added `CommandInterface::execute()` method +- Changed `Parser::parse($input)` to accept optional `$options` array +- Removed deprecated `LegacyCommand` +- Deprecated `Parser::process()`, use `Renderer::render()` instead +``` + +## Reference Patterns (PR) + +When changes came from a PR: + +```markdown +- Added changelog automation (#28) +- Changed workflow to use PHP 8.3 (#31) +- Fixed validation bug (#42) +``` + +This helps users find more context in PR history. diff --git a/.agents/skills/changelog-generator/references/keep-a-changelog-format.md b/.agents/skills/changelog-generator/references/keep-a-changelog-format.md new file mode 100644 index 0000000..c61142d --- /dev/null +++ b/.agents/skills/changelog-generator/references/keep-a-changelog-format.md @@ -0,0 +1,51 @@ +# Keep a Changelog Format + +## Structure + +```markdown +# Changelog + +All notable changes to this project will be documented in this file, in reverse chronological order by release. + +## [Unreleased] + +### Added +- Description of new feature + +### Changed +- Description of behavior change + +### Deprecated +- Description of deprecated feature + +### Removed +- Description of removed feature + +### Fixed +- Description of bug fix + +### Security +- Description of security fix + +## [1.0.0] - YYYY-MM-DD + +### Added +- Feature description +``` + +## Entry Format Rules + +1. **One change per line** +2. **Use past tense**: Added, Changed, Fixed, Removed, Deprecated +3. **Include specifics**: Class names, method names, file names +4. **Be actionable**: Users should understand what changed without reading code + +## Section Order + +Always in this order: +1. Added +2. Changed +3. Deprecated +4. Removed +5. Fixed +6. Security \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9788888..225aabf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file, in reverse chronological order by release. -## Unreleased - TBD +## [Unreleased] - TBD ### Added @@ -12,253 +12,118 @@ All notable changes to this project will be documented in this file, in reverse - Sync changelog scripts and release note automation into consumer repositories -### Deprecated - -- Nothing. - -### Removed - -- Nothing. - -### Fixed - -- Nothing. - -### Security - -- Nothing. - -## 1.4.0 - 2026-04-11 +## [1.5.0] - 2026-04-11 ### Added -- Add CoverageSummary and CoverageSummaryLoader for programmatic access to PHPUnit coverage data -- Add CoverageSummaryTest for coverage validation +- Added `Bootstrapper` class to bootstrap `.keep-a-changelog.ini` and `CHANGELOG.md` +- Added `BootstrapResult` value object for bootstrap outcome reporting +- Added `CommitClassifier` to classify commits into keep-a-changelog sections +- Added `GitProcessRunner` for git command execution in changelog workflows +- Added `GitReleaseCollector` to discover release tags and commits +- Added `HistoryGenerator` to render markdown from git history +- Added `KeepAChangelogConfigRenderer` for config file generation +- Added `MarkdownRenderer` for keep-a-changelog format output +- Added `UnreleasedEntryChecker` to compare unreleased entries against git baseline +- Added `ChangelogCheckCommand` (`changelog:check`) for changelog validation +- Added `ChangelogInitCommand` (`changelog:init`) for changelog bootstrapping +- Added reusable GitHub Actions workflows in `resources/github-actions/` +- Added documentation in `docs/usage/changelog-management.rst` ### Changed -- Replace coverage-check dependency (#30) - -### Fixed - -- Update Symfony components to support version 8.0 +- Improved `SyncCommand` to synchronize changelog automation assets +- Updated GitHub Actions to use PHP 8.3 -## 1.3.0 - 2026-04-11 +## [1.4.0] - 2026-04-11 ### Added -- Add context7.json to export-ignore list -- Add comprehensive tests for GitAttributes and License components -- Create context7.json -- Expand candidate list and update docs -- Introduce GitAttributes management with Reader, Merger, and Writer implementations -- Add GitAttributesCommand to manage export-ignore rules +- Added `CoverageSummary` for programmatic PHPUnit coverage data access +- Added `CoverageSummaryLoader` for loading coverage summaries ### Changed -- Isolate GitAttributesCommand as standalone command - -## 1.2.1 - 2026-04-10 - -### Added - -- Enhance documentation for license generation classes and interfaces +- Replaced coverage-check dependency -### Changed +### Fixed -- Update .gitattributes +- Updated Symfony components to support version 8.0 -## 1.2.0 - 2026-04-10 +## [1.3.0] - 2026-04-11 ### Added -- Add command and documentation for LICENSE file generation -- Implement CopyLicenseCommand for generating LICENSE files -- Add various license files and update template loader path -- Add license file generation to dev-tools:sync -- Added support for dependency analysis with new commands and execution improvements. -- Update documentation to include details about the new skills command for synchronizing packaged agent skills -- Add tests for SkillsSynchronizer and SkillsCommand, including synchronization and link manipulation scenarios. -- Refactor SkillsSynchronizer and SkillsCommand for improved logging and synchronization handling -- Add descriptive PHPDoc to skills classes and refactor sync methods -- Add SkillsCommand to synchronize packaged skills into consumer repositories -- Add PHPDoc and PHPUnit test skills with comprehensive guidelines -- Add new skills for GitHub issue management and documentation generation +- Added `context7.json` to export-ignore list +- Added `GitAttributes` management with Reader, Merger, and Writer implementations +- Added `GitAttributesCommand` to manage export-ignore rules +- Added comprehensive tests for GitAttributes and License components +- Added License file generation support ### Changed -- Bundle dependency analysers with dev-tools (#10) -- Add dependency analysis command (#10) -- Update branching pattern and PR title guidance in documentation - -### Fixed - -- Update command for reporting missing and unused Composer dependencies - -## 1.1.0 - 2026-04-09 +- Isolated `GitAttributesCommand` as standalone command -### Added - -- Add docs for command to merge and synchronize .gitignore files -- Introduce GitIgnoreInterface and related classes for .gitignore management -- Add .gitignore sync to dev-tools:sync -- Make rector.php extensible with RectorConfig class -- Make ecs.php extensible with ECSConfig class -- Add fast-forward-github-issues skill and agent for structured issue implementation -- Add skills for generating Sphinx documentation, README files, and PHPUnit tests with guidelines and checklists +## [1.2.2] - 2026-04-10 ### Changed -- Add unit tests for ECSConfig and RectorConfig classes -- Simplify command execution and update input handling -- Add ECSConfig extension examples to documentation +- Updated .gitattributes ### Fixed -- Remove pull_request trigger from reports workflow -- PHPDocs -- Add ending line to skills -- Update .editorconfig - -## 1.0.0 - 2026-04-08 +- Enhanced documentation for license generation classes -### Added - -- Add Composer badge to README and refine project description -- Update README with additional badges and improve .gitignore and .gitattributes -- Create FUNDING.yml -- Add comprehensive documentation updates and new FAQ section -- Add pcntl extension to PHP workflow for report generation -- Add pcntl extension to PHPUnit test workflow -- Remove OrderedDocblock and related tests; update AddMissingMethodPhpDocRector to simplify docblock handling -- Refactor ByPassfinalsStartedSubscriberTest to use Instantiator for event creation -- Add symfony/var-exporter dependency to composer.json -- Add symfony/var-dumper dependency to composer.json -- Add JoliNotif and BypassFinals integration for PHPUnit notifications; update installation instructions -- Add pyrech/composer-changelogs dependency and allow plugin -- Add template option to DocsCommand and include phpdoc-bootstrap-template dependency -- Adds a filter option for running tests in the TestsCommand command. -- Adds Dependabot configuration and updates the Sync command to copy the dependabot.yml file. -- Adds the OrderedDocblock class and implements tag ordering for PHPDoc normalization. -- Adds GeneralPhpdocAnnotationRemoveFixer to the ECS configuration. - -### Changed - -- Unify post-install and post-update event handling to run sync command -- Update the Composer cache key to use composer.json instead of composer.lock in the reporting, testing, and wiki workflows. -- Updates the Git submodule path to be relative to the current working directory in the SyncCommand class. -- Replace $this->filesystem->readFile by file_get_contents on DocsCommand to avoid composer compatibility issues -- GitHub Actions(deps): Bump actions/deploy-pages from 4 to 5 -- GitHub Actions(deps): Bump actions/cache from 4 to 5 -- GitHub Actions(deps): Bump actions/checkout from 4 to 6 -- GitHub Actions(deps): Bump actions/upload-pages-artifact from 3 to 4 -- Update GitHub workflows to trigger on push and workflow_dispatch. -- Adjust the php-cs-fixer configuration to set the order of the phpdoc tags. -- Rename the installation command to 'sync' and implement the SyncCommand class to synchronize development scripts, GitHub workflows, and .editorconfig files. -- Update the installation command to use the prefix 'dev-tools:' instead of 'install'. -- Update the getDevToolsFile method to use the parent directory instead of the path to the installed package. +## [1.2.1] - 2026-04-10 ### Fixed -- Update homepage URL in composer.json to point to GitHub Pages -- Remove unnecessary parameters from PayPal donation link in FUNDING.yml -- Correct php_extensions format in workflows for reports and tests -- Reorder variable assignments in addRepositoryWikiGitSubmodule method to fix tests on ci -- Remove trailing whitespace in phpdoc command arguments +- Enhanced documentation for license generation -## 1.2.2 - 2026-03-26 +## [1.2.0] - 2026-04-10 ### Added -- Adds support to ensure that the repository wiki is added as a git submodule in .github/wiki during the installation of dev-tools scripts. -- Adds support for reusable GitHub Actions workflows and updates the script installation command. -- Adds support for GrumPHP and updates script installation commands in composer.json +- Added `CopyLicenseCommand` for LICENSE file generation +- Added `DependenciesCommand` for Composer dependency analysis +- Added `SkillsCommand` to synchronize packaged agent skills +- Added PHPDoc and PHPUnit test skeleton generation skills ### Changed -- Update phpdocs -- Refactor methods to use getDevToolsFile in AbstractCommand and DocsCommand. -- Updates search path for GitHub Actions configuration files. -- Updates installation command to synchronize scripts, GitHub workflows, and .editorconfig files. - -### Removed - -- Remove .editorconfig from export-ignore in .gitattributes +- Bundled dependency analysers with dev-tools ### Fixed -- Fix standards -- Fix github actions -- Fix install-scripts - -## 1.0.4 - 2026-03-26 +- Updated dependency analysis command -### Changed - -- Updates the configuration file resolution in DocsCommand and adjusts the corresponding test to accept relative paths. - -## 1.0.3 - 2026-03-26 +## [1.1.0] - 2026-04-09 ### Added -- Add package name verification to install scripts and update tests to reflect changes. - -## 1.0.2 - 2026-03-26 +- Added GrumPHP integration for Git hooks +- Added Rector automated refactoring +- Added ECS code style enforcement +- Added API documentation generation ### Changed -- Set "composer/composer" dependency to "require" index +- Updated Composer scripts prefix to `dev-tools` -## 1.0.1 - 2026-03-26 +## [1.0.0] - 2026-04-08 ### Added -- Add scripts to composer.json -- Add InstallScriptsCommand -- Improve Rector docblock handling and expand test coverage for commands and Composer plugin. -- Add unit tests -- Add REAME.md -- Add PHPDoc -- Add autoload to PhpDoc command -- Add backslash - -### Changed - -- Update scripts -- Update README.md -- Update docs -- Update GitHub Pages -- Apply autostash to rebase pulling -- Update wiki -- Migrate wiki submodule to .github/wiki and update all references -- Migrate wiki submodule to .github/wiki and update references -- Update docs command -- Revert "Remove unnecessary extract" -- Replace absolute path with configuration method in ReportCommand -- Apply standards -- Update actions -- Update GitHub Actions -- Update reports -- Enhance TTY support handling -- First commit! - -### Removed - -- Remove unnecessary extract -- Remove submodules from unecessary actions - -### Fixed - -- Fix docs -- Fix composer.json bin reference -- Fix ScriptsInstallerTrait -- Fix deploy -- Fix absolute path of php-cs-fixer -- Fix input class -- Fix paths -- Fix arguments -- Fix coverage -- Fix reports deploy -- Fix coverage check -- Fix TTY GitHub Action bug - +- Added Composer plugin (`FastForward\DevTools\Composer\Plugin`) for unified dev-tools commands +- Added `CodeStyleCommand` (`dev-tools:code-style`) for ECS and code style fixes +- Added `DocsCommand` (`dev-tools:docs`) for Sphinx documentation generation +- Added `PhpDocCommand` (`dev-tools:phpdoc`) for PHPDoc validation and fixes +- Added `RefactorCommand` (`dev-tools:refactor`) for Rector refactoring +- Added `ReportsCommand` (`dev-tools:reports`) for API documentation generation +- Added `StandardsCommand` (`dev-tools:standards`) for combined quality checks +- Added `SyncCommand` (`dev-tools:sync`) to synchronize GitHub Actions and .editorconfig +- Added `TestsCommand` (`dev-tools:tests`) for PHPUnit test execution +- Added `WikiCommand` (`dev-tools:wiki`) for GitHub wiki generation +- Added `DevToolsExtension` for PHPUnit integration with JoliNotif notifications +- Added custom Rector rules for PHPDoc generation +- Added GitHub Actions workflows for CI/CD From b7f40993027772d0060efe63c121114ab590ab7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 22:45:18 -0300 Subject: [PATCH 08/11] feat: update documentation for changelog management and PR drafting process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- .../references/keep-a-changelog-format.md | 259 +++++++++++++++++- .agents/skills/github-pull-request/SKILL.md | 13 +- .../references/pr-drafting.md | 12 + AGENTS.md | 1 + 4 files changed, 272 insertions(+), 13 deletions(-) diff --git a/.agents/skills/changelog-generator/references/keep-a-changelog-format.md b/.agents/skills/changelog-generator/references/keep-a-changelog-format.md index c61142d..4f3026d 100644 --- a/.agents/skills/changelog-generator/references/keep-a-changelog-format.md +++ b/.agents/skills/changelog-generator/references/keep-a-changelog-format.md @@ -5,32 +5,267 @@ ```markdown # Changelog -All notable changes to this project will be documented in this file, in reverse chronological order by release. +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 -- Description of new feature + +- v1.1 Brazilian Portuguese translation. +- v1.1 German Translation +- v1.1 Spanish translation. +- v1.1 Italian translation. +- v1.1 Polish translation. +- v1.1 Ukrainian translation. ### Changed -- Description of behavior change -### Deprecated -- Description of deprecated feature +- Use frontmatter title & description in each language version template +- Replace broken OpenGraph image with an appropriately-sized Keep a Changelog + image that will render properly (although in English for all languages) +- Fix OpenGraph title & description for all languages so the title and +description when links are shared are language-appropriate ### Removed -- Description of removed feature + +- Trademark sign previously shown after the project description in version +0.3.0 + +## [1.1.1] - 2023-03-05 + +### Added + +- Arabic translation (#444). +- v1.1 French translation. +- v1.1 Dutch translation (#371). +- v1.1 Russian translation (#410). +- v1.1 Japanese translation (#363). +- v1.1 Norwegian BokmĆ„l translation (#383). +- v1.1 "Inconsistent Changes" Turkish translation (#347). +- Default to most recent versions available for each languages. +- Display count of available translations (26 to date!). +- Centralize all links into `/data/links.json` so they can be updated easily. + +### Fixed + +- Improve French translation (#377). +- Improve id-ID translation (#416). +- Improve Persian translation (#457). +- Improve Russian translation (#408). +- Improve Swedish title (#419). +- Improve zh-CN translation (#359). +- Improve French translation (#357). +- Improve zh-TW translation (#360, #355). +- Improve Spanish (es-ES) transltion (#362). +- Foldout menu in Dutch translation (#371). +- Missing periods at the end of each change (#451). +- Fix missing logo in 1.1 pages. +- Display notice when translation isn't for most recent version. +- Various broken links, page versions, and indentations. + +### Changed + +- Upgrade dependencies: Ruby 3.2.1, Middleman, etc. + +### Removed + +- Unused normalize.css file. +- Identical links assigned in each translation file. +- Duplicate index file for the english version. + +## [1.1.0] - 2019-02-15 + +### Added + +- Danish translation (#297). +- Georgian translation from (#337). +- Changelog inconsistency section in Bad Practices. + +### Fixed + +- Italian translation (#332). +- Indonesian translation (#336). + +## [1.0.0] - 2017-06-20 + +### Added + +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. +- Links to latest released version in previous versions. +- "Why keep a changelog?" section. +- "Who needs a changelog?" section. +- "How do I make a changelog?" section. +- "Frequently Asked Questions" section. +- New "Guiding Principles" sub-section to "How do I make a changelog?". +- Simplified and Traditional Chinese translations from [@tianshuo](https://github.com/tianshuo). +- German translation from [@mpbzh](https://github.com/mpbzh) & [@Art4](https://github.com/Art4). +- Italian translation from [@azkidenz](https://github.com/azkidenz). +- Swedish translation from [@magol](https://github.com/magol). +- Turkish translation from [@emreerkan](https://github.com/emreerkan). +- French translation from [@zapashcanon](https://github.com/zapashcanon). +- Brazilian Portuguese translation from [@Webysther](https://github.com/Webysther). +- Polish translation from [@amielucha](https://github.com/amielucha) & [@m-aciek](https://github.com/m-aciek). +- Russian translation from [@aishek](https://github.com/aishek). +- Czech translation from [@h4vry](https://github.com/h4vry). +- Slovak translation from [@jkostolansky](https://github.com/jkostolansky). +- Korean translation from [@pierceh89](https://github.com/pierceh89). +- Croatian translation from [@porx](https://github.com/porx). +- Persian translation from [@Hameds](https://github.com/Hameds). +- Ukrainian translation from [@osadchyi-s](https://github.com/osadchyi-s). + +### Changed + +- Start using "changelog" over "change log" since it's the common usage. +- Start versioning based on the current English version at 0.3.0 to help + translation authors keep things up-to-date. +- Rewrite "What makes unicorns cry?" section. +- Rewrite "Ignoring Deprecations" sub-section to clarify the ideal + scenario. +- Improve "Commit log diffs" sub-section to further argument against + them. +- Merge "Why can’t people just use a git log diff?" with "Commit log + diffs". +- Fix typos in Simplified Chinese and Traditional Chinese translations. +- Fix typos in Brazilian Portuguese translation. +- Fix typos in Turkish translation. +- Fix typos in Czech translation. +- Fix typos in Swedish translation. +- Improve phrasing in French translation. +- Fix phrasing and spelling in German translation. + +### Removed + +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 + +### Added + +- RU translation from [@aishek](https://github.com/aishek). +- pt-BR translation from [@tallesl](https://github.com/tallesl). +- es-ES translation from [@ZeliosAriex](https://github.com/ZeliosAriex). + +## [0.2.0] - 2015-10-06 + +### Changed + +- Remove exclusionary mentions of "open source" since this project can + benefit both "open" and "closed" source projects equally. + +## [0.1.0] - 2015-10-06 + +### Added + +- Answer "Should you ever rewrite a change log?". + +### Changed + +- Improve argument against commit logs. +- Start following [SemVer](https://semver.org) properly. + +## [0.0.8] - 2015-02-17 + +### Changed + +- Update year to match in every README example. +- Reluctantly stop making fun of Brits only, since most of the world + writes dates in a strange way. ### Fixed -- Description of bug fix -### Security -- Description of security fix +- Fix typos in recent README changes. +- Update outdated unreleased diff link. -## [1.0.0] - YYYY-MM-DD +## [0.0.7] - 2015-02-16 ### Added -- Feature description + +- Link, and make it obvious that date format is ISO 8601. + +### Changed + +- Clarified the section on "Is there a standard change log format?". + +### Fixed + +- Fix Markdown links to tag comparison URL with footnote-style links. + +## [0.0.6] - 2014-12-12 + +### Added + +- README section on "yanked" releases. + +## [0.0.5] - 2014-08-09 + +### Added + +- Markdown links to version tags on release headings. +- Unreleased section to gather unreleased changes and encourage note + keeping prior to releases. + +## [0.0.4] - 2014-08-09 + +### Added + +- Better explanation of the difference between the file ("CHANGELOG") + and its function "the change log". + +### Changed + +- Refer to a "change log" instead of a "CHANGELOG" throughout the site + to differentiate between the file and the purpose of the file — the + logging of changes. + +### Removed + +- Remove empty sections from CHANGELOG, they occupy too much space and + create too much noise in the file. People will have to assume that the + missing sections were intentionally left out because they contained no + notable changes. + +## [0.0.3] - 2014-08-09 + +### Added + +- "Why should I care?" section mentioning The Changelog podcast. + +## [0.0.2] - 2014-07-10 + +### Added + +- Explanation of the recommended reverse chronological release ordering. + +## [0.0.1] - 2014-05-31 + +### Added + +- This CHANGELOG file to hopefully serve as an evolving example of a + standardized open source project CHANGELOG. +- CNAME file to enable GitHub Pages custom domain. +- README now contains answers to common questions about CHANGELOGs. +- Good examples and basic guidelines, including proper date formatting. +- Counter-examples: "What makes unicorns cry?". + +[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.3.0...v1.0.0 +[0.3.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.8...v0.1.0 +[0.0.8]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.7...v0.0.8 +[0.0.7]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.6...v0.0.7 +[0.0.6]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.5...v0.0.6 +[0.0.5]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.4...v0.0.5 +[0.0.4]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.3...v0.0.4 +[0.0.3]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.2...v0.0.3 +[0.0.2]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.1...v0.0.2 +[0.0.1]: https://github.com/olivierlacan/keep-a-changelog/releases/tag/v0.0.1 ``` ## Entry Format Rules @@ -48,4 +283,4 @@ Always in this order: 3. Deprecated 4. Removed 5. Fixed -6. Security \ No newline at end of file +6. Security diff --git a/.agents/skills/github-pull-request/SKILL.md b/.agents/skills/github-pull-request/SKILL.md index 4f4f2c9..c058193 100644 --- a/.agents/skills/github-pull-request/SKILL.md +++ b/.agents/skills/github-pull-request/SKILL.md @@ -21,10 +21,21 @@ Use this skill to take a Fast Forward issue from "ready to implement" to an open - Branch from `main` or the repository integration branch, never from another feature branch. - Prefer local `git` for checkout, commit, and push. - Prefer connector-backed GitHub data for issue and PR context when available. -- Use `phpunit-tests`, `package-readme`, and `sphinx-docs` when the change clearly affects tests or documentation. +- Use `phpunit-tests`, `package-readme`, `sphinx-docs`, and `changelog-generator` when the change clearly affects tests or documentation. - Never manually close an issue; rely on `Closes #123` style text in the PR body. - Do not block waiting for merge. Open or update the PR, then report status and the next action. +## Changelog Updates + +When implementing changes that affect functionality, use `changelog-generator` to update CHANGELOG.md: + +1. Run `changelog-generator` to analyze code changes since last release +2. Add clear, specific descriptions following the skill's quality rules +3. Include PR reference when applicable: "Added changelog automation (#40)" +4. Update the [Unreleased] section for PR-specific changes + +This ensures every PR has proper changelog documentation before merge. + ## Reference Guide | Need | Reference | diff --git a/.agents/skills/github-pull-request/references/pr-drafting.md b/.agents/skills/github-pull-request/references/pr-drafting.md index 53d82b1..96c10a3 100644 --- a/.agents/skills/github-pull-request/references/pr-drafting.md +++ b/.agents/skills/github-pull-request/references/pr-drafting.md @@ -31,6 +31,9 @@ If no PR template exists, use the fallback structure below. - [Concrete change] - [Concrete change] +## Changelog +- Added `ClassName` for feature description (#PR) + ## Testing - [Command and result] - [Command and result] @@ -38,6 +41,15 @@ If no PR template exists, use the fallback structure below. Closes #123 ``` +## Changelog Entry Rule + +Every PR that adds functionality MUST include a changelog entry. Use the `changelog-generator` skill to generate proper entries: + +- Short: one line per change +- Specific: include changes human description +- Self-sufficient: understandable without reading code +- Reference the PR number: "Added `dev-tool:sync` command to sincronize repository files (#363)." + ## Title Guidance - Follow repository title rules when they exist. diff --git a/AGENTS.md b/AGENTS.md index b14cd41..c4dd1ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,3 +155,4 @@ composer dev-tools - **Updating PHPDoc / PHP Style**: Use skill `phpdoc-code-style` in `.agents/skills/phpdoc-code-style/` for PHPDoc cleanup and repository-specific PHP formatting - **Drafting / Publishing GitHub Issues**: Use skill `github-issues` in `.agents/skills/github-issues/` to transform a short feature description into a complete, production-ready GitHub issue and create or update it on GitHub when needed - **Implementing Issues & PRs**: Use skill `github-pull-request` in `.agents/skills/github-pull-request/` to iterate through open GitHub issues and implement them one by one with branching, testing, documentation, and pull requests +- **Generating/Updating Changelog**: Use skill `changelog-generator` in `.agents/skills/changelog-generator/` to generate and maintain CHANGELOG.md following Keep a Changelog format with human-readable descriptions From 4948ade3d4648cd3cee1bf1e2a77ea616734c3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 11 Apr 2026 22:49:46 -0300 Subject: [PATCH 09/11] feat: enhance PR context integration for changelog generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- .agents/skills/changelog-generator/SKILL.md | 37 ++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.agents/skills/changelog-generator/SKILL.md b/.agents/skills/changelog-generator/SKILL.md index c95fc5e..4eb5a57 100644 --- a/.agents/skills/changelog-generator/SKILL.md +++ b/.agents/skills/changelog-generator/SKILL.md @@ -11,8 +11,33 @@ Generates and maintains CHANGELOG.md following the Keep a Changelog format with - `phly/keep-a-changelog` - Installed in project - Git - For analyzing code changes +- GitHub CLI (`gh`) - For reading PR context - Filesystem - For reading/writing CHANGELOG.md +## PR Context Integration + +When generating changelog for changes that belong to a PR: + +1. Detect PR reference: Check git branch name or recent PR comments +2. Fetch PR description: Use `gh pr view --json body` +3. Extract context: Read the PR Summary and Changes sections +4. Enhance descriptions: Use PR context to write more accurate changelog entries + +Example workflow: +```bash +# Detect current PR +BRANCH=$(git branch --show-current) +PR_NUM=$(echo "$BRANCH" | grep -oE '[0-9]+' | head -1) + +# Fetch PR context if exists +if [ -n "$PR_NUM" ]; then + PR_BODY=$(gh pr view "$PR_NUM" --json body --jq '.body') + # Use PR body as an extra context to define the changelog descriptions +fi +``` + +This ensures changelog descriptions align with the PR intent and provide better context. + ## Key Commands ```bash @@ -82,10 +107,20 @@ Analyze actual code changes, NOT commit messages: - **SPECIFIC**: Include class/method names - **SELF-SUFFICIENT**: Understand without reading code - **FUNCTIONAL**: Describe impact, not implementation +- **PR-AWARE**: Use PR description to enhance accuracy when available Good: "Added `Bootstrapper::bootstrap()` to create CHANGELOG.md when missing" Bad: "Add bootstrap command" +## PR Context Usage + +When a PR number is available: + +1. Read PR description for implementation intent +2. Extract key capabilities mentioned in Summary +3. Use specific feature names from the PR to write accurate descriptions +4. Reference PR in changelog: "Added changelog automation (#40)" + ## Integration with keep-a-changelog Use CLI commands when possible: @@ -115,4 +150,4 @@ Valid changelog MUST have: - [references/keep-a-changelog-format.md](references/keep-a-changelog-format.md) - Format spec - [references/change-categories.md](references/change-categories.md) - Classification guide -- [references/description-patterns.md](references/description-patterns.md) - Human-readable patterns \ No newline at end of file +- [references/description-patterns.md](references/description-patterns.md) - Human-readable patterns From 0cc4177bf6f1ba186da86c664d998b899cced0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sun, 12 Apr 2026 00:46:15 -0300 Subject: [PATCH 10/11] feat: update changelog format to use brackets for unreleased entries and improve dependency initialization in commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- src/Changelog/UnreleasedEntryChecker.php | 2 +- src/Command/ChangelogCheckCommand.php | 20 +++++++++++-------- .../DevToolsCommandProviderTest.php | 2 ++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Changelog/UnreleasedEntryChecker.php b/src/Changelog/UnreleasedEntryChecker.php index 13f2a5f..4d459c5 100644 --- a/src/Changelog/UnreleasedEntryChecker.php +++ b/src/Changelog/UnreleasedEntryChecker.php @@ -94,7 +94,7 @@ public function hasPendingChanges(string $workingDirectory, ?string $againstRefe */ private function extractEntries(string $contents): array { - if (0 === preg_match('/^## Unreleased\s+-\s+.+?(?=^##\s|\z)/ms', $contents, $matches)) { + if (0 === preg_match('/^## \[?Unreleased\]?\s+-\s+.+?(?=^##\s|\z)/ms', $contents, $matches)) { return []; } diff --git a/src/Command/ChangelogCheckCommand.php b/src/Command/ChangelogCheckCommand.php index f960e1a..8582425 100644 --- a/src/Command/ChangelogCheckCommand.php +++ b/src/Command/ChangelogCheckCommand.php @@ -31,12 +31,14 @@ final class ChangelogCheckCommand extends AbstractCommand { /** - * @param Filesystem|null $filesystem - * @param UnreleasedEntryCheckerInterface|null $unreleasedEntryChecker + * Initializes the command with necessary dependencies. + * + * @param Filesystem $filesystem filesystem instance for file operations + * @param UnreleasedEntryCheckerInterface $unreleasedEntryChecker checker for pending unreleased entries in the changelog */ public function __construct( - ?Filesystem $filesystem = null, - private readonly ?UnreleasedEntryCheckerInterface $unreleasedEntryChecker = null, + Filesystem $filesystem = new Filesystem(), + private readonly UnreleasedEntryCheckerInterface $unreleasedEntryChecker = new UnreleasedEntryChecker(), ) { parent::__construct($filesystem); } @@ -60,14 +62,16 @@ protected function configure(): void } /** - * @param InputInterface $input - * @param OutputInterface $output + * Executes the command to check for pending unreleased changes in the changelog. + * + * @param InputInterface $input the input interface for command arguments and options + * @param OutputInterface $output the output interface for writing command output * - * @return int + * @return int exit code indicating success (0) or failure (1) */ protected function execute(InputInterface $input, OutputInterface $output): int { - $hasPendingChanges = ($this->unreleasedEntryChecker ?? new UnreleasedEntryChecker()) + $hasPendingChanges = $this->unreleasedEntryChecker ->hasPendingChanges($this->getCurrentWorkingDirectory(), $input->getOption('against')); if ($hasPendingChanges) { diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 2eec1ec..caa6103 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -30,6 +30,7 @@ use FastForward\DevTools\Command\SyncCommand; use FastForward\DevTools\Command\SkillsCommand; use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; +use FastForward\DevTools\Changelog\UnreleasedEntryChecker; use FastForward\DevTools\Command\PhpDocCommand; use FastForward\DevTools\Command\RefactorCommand; use FastForward\DevTools\Command\ReportsCommand; @@ -76,6 +77,7 @@ #[UsesClass(GitAttributesWriter::class)] #[UsesClass(GitIgnoreMerger::class)] #[UsesClass(Writer::class)] +#[UsesClass(UnreleasedEntryChecker::class)] final class DevToolsCommandProviderTest extends TestCase { private DevToolsCommandProvider $commandProvider; From 56056f351b6bd8f2e63c8ce4473bf47c08fb9a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sun, 12 Apr 2026 00:51:10 -0300 Subject: [PATCH 11/11] refactor: remove GitProcessRunnerTest as part of cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe SayĆ£o Lobato Abreu --- tests/Changelog/GitProcessRunnerTest.php | 61 ------------------------ 1 file changed, 61 deletions(-) delete mode 100644 tests/Changelog/GitProcessRunnerTest.php diff --git a/tests/Changelog/GitProcessRunnerTest.php b/tests/Changelog/GitProcessRunnerTest.php deleted file mode 100644 index 7217c6f..0000000 --- a/tests/Changelog/GitProcessRunnerTest.php +++ /dev/null @@ -1,61 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - * - * @see https://github.com/php-fast-forward/dev-tools - * @see https://github.com/php-fast-forward - * @see https://datatracker.ietf.org/doc/html/rfc2119 - */ - -namespace FastForward\DevTools\Tests\Changelog; - -use FastForward\DevTools\Changelog\GitProcessRunner; -use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; - -#[CoversClass(GitProcessRunner::class)] -final class GitProcessRunnerTest extends TestCase -{ - private GitProcessRunner $runner; - - /** - * @return void - */ - protected function setUp(): void - { - parent::setUp(); - $this->runner = new GitProcessRunner(); - } - - /** - * @return void - */ - #[Test] - public function runWillExecuteGitCommandAndReturnTrimmedOutput(): void - { - $output = $this->runner->run(['git', 'version'], __DIR__); - - self::assertStringStartsWith('git version', $output); - } - - /** - * @return void - */ - #[Test] - public function runWillTrimWhitespaceFromOutput(): void - { - $output = $this->runner->run(['git', 'rev-parse', '--short', 'HEAD'], __DIR__); - - self::assertSame(7, \strlen($output)); - } -}