diff --git a/.agents/skills/changelog-generator/SKILL.md b/.agents/skills/changelog-generator/SKILL.md new file mode 100644 index 0000000..ea8e218 --- /dev/null +++ b/.agents/skills/changelog-generator/SKILL.md @@ -0,0 +1,106 @@ +--- +name: changelog-generator +description: Generate or refresh human-readable CHANGELOG.md files that follow Keep a Changelog by comparing git tags and code diffs instead of commit messages. Use when an agent needs to bootstrap a changelog for a repository, backfill undocumented tagged releases, update release entries, or rewrite the Unreleased section for current branch work using the phly/keep-a-changelog commands available in the project. +--- + +# Changelog Generator + +Generate changelog entries that a reader can understand without opening the code. + +## Deterministic Helpers + +Use the bundled PHP scripts before manual analysis: + +```bash +php .agents/skills/changelog-generator/scripts/changelog-state.php +php .agents/skills/changelog-generator/scripts/diff-inventory.php +``` + +- `changelog-state.php` reports changelog presence, documented versions, discovered tags, undocumented tags, and suggested release ranges as JSON. +- `diff-inventory.php` reports changed files, line counts, and likely user-visible paths for a specific diff range as JSON. +- Both scripts auto-discover the repository root and opportunistically load `vendor/autoload.php` when it exists. + +## Workflow + +1. Establish current state. +- Read `CHANGELOG.md` if it exists. +- Prefer `changelog-state.php` to gather versions and ranges before inspecting files manually. +- Record documented versions and whether the official `## [Unreleased]` heading already exists. +- List tags in ascending semantic order with `git tag --sort=version:refname`, and capture their commit dates when the repository may have retroactive or out-of-sequence tags. +- Treat commit messages as navigation hints only; never derive final changelog text from them. + +2. Choose diff ranges. +- If `CHANGELOG.md` is missing or empty, analyze each tag range from the first tagged version onward. +- If `CHANGELOG.md` already documents releases, start at the first tag after the last documented version. +- When tag publication order differs from semantic order, prefer the actual tag chronology for release ordering and use diffs that follow that real release sequence. +- Build `Unreleased` from the diff between the latest documented release or tag and `HEAD`. + +3. Analyze changes from diffs. +- Prefer `diff-inventory.php ` first so you can focus on the files most likely to affect user-visible behavior. +- Start with `git diff --name-status ` and `git diff --stat `. +- Open targeted `git diff --unified=0 -- ` views for files that define public behavior, commands, config, schemas, workflows, or exposed APIs. +- Classify entries by observed impact: + - `Added`: new files, APIs, commands, options, configuration, workflows, or user-visible capabilities + - `Changed`: modified behavior, signature or default changes, renamed flows, or compatibility-preserving refactors with visible impact + - `Fixed`: bug fixes, validation corrections, edge-case handling, or broken workflows + - `Removed`: deleted APIs, commands, config, or capabilities + - `Deprecated`: explicit deprecation notices or migration paths + - `Security`: hardening or vulnerability fixes +- Skip pure churn that a reader would not care about unless it changes behavior or release expectations. +- Deduplicate multiple file changes that describe the same user-visible outcome. + +4. Write human-readable entries. +- Write one line per change. +- Prefer the functional effect over implementation detail. +- Mention the concrete command, class, option, workflow, or API when that improves comprehension. +- When a matching PR exists, append it to the line in the format `(#123)` after the diff already supports the entry. +- Avoid vague phrases such as `misc improvements`, `refactorings`, or `code cleanup`. +- Keep the file structure compliant with Keep a Changelog 1.1.0: bracketed version headings, the official intro paragraph, and footer references for `Unreleased` and each version. +- Omit empty sections instead of inserting placeholder entries such as `Nothing.`. + +5. Apply changes with project tooling. +- Prefer the local wrappers when available: + +```bash +composer dev-tools changelog:init +composer dev-tools changelog:check +``` + +- Use the official CLI for entries and releases: + +```bash +vendor/bin/keep-a-changelog entry:added "..." +vendor/bin/keep-a-changelog entry:changed "..." +vendor/bin/keep-a-changelog entry:fixed "..." +vendor/bin/keep-a-changelog unreleased:create --no-interaction +vendor/bin/keep-a-changelog unreleased:promote 1.2.0 --date=2026-04-12 --no-interaction +vendor/bin/keep-a-changelog version:show 1.2.0 +vendor/bin/keep-a-changelog version:release 1.2.0 --provider-token=... +``` + +- For large historical backfills, direct markdown editing is acceptable for the first draft. After that, use the CLI to keep `Unreleased` and future entries consistent. + +6. Verify the result. +- Keep `Unreleased` first and released versions in reverse chronological order. +- Keep section order as `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Do not duplicate the same change across sections or versions. +- Ensure every documented version maps to a real tag or intentional unreleased state. +- Ensure footer references exist in the official style: `[unreleased]: ...`, `[1.2.0]: ...`. +- Run local helpers such as `composer dev-tools changelog:check` when the project provides them. + +## PR Context + +Use PR descriptions, issue text, or release notes only to refine wording after diff analysis confirms the change. Good uses: + +- naming a feature exactly as presented to users +- adding a stable reference like `(#123)` +- understanding why a visible change matters when the diff alone is ambiguous + +Do not use PR text to invent entries that are not supported by the code diff. + +## Reference Files + +- Read [references/keep-a-changelog-format.md](references/keep-a-changelog-format.md) for heading format, section order, and CLI mapping. +- Read [references/official-example-template.md](references/official-example-template.md) when you want a local template that mirrors the official Keep a Changelog example. +- Read [references/change-categories.md](references/change-categories.md) when the diff spans multiple change types. +- Read [references/description-patterns.md](references/description-patterns.md) when the first draft still sounds too internal or vague. diff --git a/.agents/skills/changelog-generator/agents/openai.yaml b/.agents/skills/changelog-generator/agents/openai.yaml new file mode 100644 index 0000000..e8fcf00 --- /dev/null +++ b/.agents/skills/changelog-generator/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Changelog Generator" + short_description: "Help with Changelog Generator tasks" 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..2a9c059 --- /dev/null +++ b/.agents/skills/changelog-generator/references/change-categories.md @@ -0,0 +1,97 @@ +# 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 +- New workflows or automation files +- New user-visible documentation pages that introduce a capability + +**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 +- Workflow or release process changes that alter contributor expectations + +**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 +- Broken automation or CI repairs + +**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 +- Removed commands or workflows + +**Examples:** +- `-class LegacyParser` → "Removed deprecated `LegacyParser` class" +- `-function oldMethod()` → "Removed deprecated `oldMethod()` method" + +## Deprecated + +**Patterns:** +- @deprecated annotations +- Deprecation notices in code +- Migration warnings that keep the old surface available for now + +**Examples:** +- `@deprecated` → "Deprecated `LegacyParser`, use `MarkdownParser` instead" + +## Security + +**Patterns:** +- Security patches +- Vulnerability fixes +- Input sanitization +- Permission hardening or secret-handling fixes + +**Examples:** +- XSS fixes → "Fixed XSS vulnerability in user input" +- CSRF protection → "Added CSRF protection to form handling" + +## Tie-breakers + +When a change could fit multiple categories, prefer the most specific outcome: + +1. `Security` over every other category +2. `Removed` when the old surface is actually gone +3. `Deprecated` when the old surface still exists but has a migration path +4. `Fixed` for bug repairs, even if files or methods were added to implement the fix +5. `Added` for genuinely new capability +6. `Changed` as the fallback for user-visible behavior shifts + +## Skip or compress + +- Skip purely internal renames, file moves, or test-only churn unless they change behavior or contributor workflow. +- Compress multiple file edits into one entry when they describe the same visible outcome. 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..8b7d106 --- /dev/null +++ b/.agents/skills/changelog-generator/references/description-patterns.md @@ -0,0 +1,66 @@ +# Description Patterns + +## How to Write Human-Readable Descriptions + +Rule: Describe the IMPACT, not the IMPLEMENTATION. + +## Checklist + +- Keep each entry to one line. +- Name the surface that changed: class, command, option, workflow, API, or config. +- Describe the user-visible effect first. +- Avoid implementation verbs such as `extract`, `rename`, `refactor`, or `reorganize` unless the refactor itself changes behavior. + +## 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: "Changed changelog generation to classify release entries by observed diff impact" + +Bad: "fix: validate unreleased notes" +Good: "Fixed validation of unreleased changelog entries" + +Bad: "chore: update dependencies" +Good: N/A - Skip infrastructure-only changes +``` + +## Description templates + +Use these patterns when they fit the diff: + +```markdown +- Added `` to `` for `` +- Changed `` to `` +- Fixed `` in `` +- Removed deprecated `` +- Deprecated ``; use `` +``` + +## Concrete examples + +```markdown +- Added `changelog:init` to bootstrap `.keep-a-changelog.ini` and `CHANGELOG.md` +- Changed changelog sync to install reusable release-note workflows +- Fixed bootstrap of the `Unreleased` section for existing changelog files +- Removed deprecated `LegacyCommand` +- Deprecated `Parser::process()`; use `Renderer::render()` instead +``` + +## Optional references + +Append issue or PR references only when they add useful context and the diff already supports the entry: + +```markdown +- Added changelog automation (#40) +- Changed workflow to use PHP 8.3 (#31) +- Fixed validation bug (#42) +``` + +When a matching pull request exists, prefer appending the PR reference in the format `(#123)` at the end of the line. + +Do not rely on the PR text as the source of truth. 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..c7b0d94 --- /dev/null +++ b/.agents/skills/changelog-generator/references/keep-a-changelog-format.md @@ -0,0 +1,99 @@ +# Keep a Changelog 1.1.0 Format + +Use the official Keep a Changelog 1.1.0 structure as the default target format: + +- Official guidance: `https://keepachangelog.com/en/1.1.0/` +- Official example: `https://keepachangelog.com/en/1.1.0/#how` + +## Required introduction + +Mirror the official introduction exactly unless the repository already has an approved custom introduction: + +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +``` + +## Required heading shape + +Use bracketed headings and footer references exactly like the official example: + +```markdown +## [Unreleased] + +### Added +- ... + +## [1.4.0] - 2026-04-11 + +### Changed +- ... +``` + +## Footer references + +Versions and `Unreleased` SHOULD be linkable through footer references in the official style: + +```markdown +[unreleased]: https://github.com///compare/v1.4.0...HEAD +[1.4.0]: https://github.com///compare/v1.3.0...v1.4.0 +[1.3.0]: https://github.com///compare/v1.2.0...v1.3.0 +[1.0.0]: https://github.com///releases/tag/v1.0.0 +``` + +Rules: + +- `Unreleased` compares the latest documented tag to `HEAD`. +- Each released version compares the previous documented release tag to the current tag. +- The oldest documented release links to its release page when no older release exists in the changelog. +- When tags were published out of semantic order, keep the changelog ordered by actual release chronology and generate comparison links between adjacent displayed releases. + +## Section order + +Keep change types grouped in this order: + +1. `Added` +2. `Changed` +3. `Deprecated` +4. `Removed` +5. `Fixed` +6. `Security` + +## Compliance rules from the official guidance + +- Changelogs are for humans, not machines. +- There SHOULD be an entry for every single version. +- The same types of changes SHOULD be grouped. +- Versions and sections SHOULD be linkable. +- The latest version SHOULD come first. +- The release date of each version SHOULD be displayed in ISO 8601 format: `YYYY-MM-DD`. +- Mention whether the project follows Semantic Versioning. +- Omit empty sections instead of filling them with placeholders such as `Nothing.`. + +## CLI mapping + +Check available commands locally when unsure: + +```bash +vendor/bin/keep-a-changelog list --raw +``` + +Most common commands: + +```bash +composer dev-tools changelog:init +composer dev-tools changelog:check +vendor/bin/keep-a-changelog entry:added "..." +vendor/bin/keep-a-changelog entry:changed "..." +vendor/bin/keep-a-changelog entry:fixed "..." +vendor/bin/keep-a-changelog entry:removed "..." +vendor/bin/keep-a-changelog entry:deprecated "..." +vendor/bin/keep-a-changelog unreleased:create --no-interaction +vendor/bin/keep-a-changelog unreleased:promote 1.2.0 --date=2026-04-12 --no-interaction +vendor/bin/keep-a-changelog version:show 1.2.0 +vendor/bin/keep-a-changelog version:release 1.2.0 --provider-token=... +``` diff --git a/.agents/skills/changelog-generator/references/official-example-template.md b/.agents/skills/changelog-generator/references/official-example-template.md new file mode 100644 index 0000000..efcfe4f --- /dev/null +++ b/.agents/skills/changelog-generator/references/official-example-template.md @@ -0,0 +1,44 @@ +# Official Example Template + +This is a repository-adapted template that mirrors the official Keep a Changelog 1.1.0 example structure. + +Source of truth: + +- `https://keepachangelog.com/en/1.1.0/` + +Use this shape when drafting or rewriting `CHANGELOG.md`: + +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Added `ExampleCommand` to bootstrap changelog automation (#40) + +### Changed +- Changed release notes automation to export changelog entries directly from `CHANGELOG.md` (#40) + +## [1.0.0] - 2026-04-08 + +### Added +- Initial public release of the package + +### Fixed +- Fixed release metadata for the first tagged version + +[unreleased]: https://github.com///compare/v1.0.0...HEAD +[1.0.0]: https://github.com///releases/tag/v1.0.0 +``` + +Notes: + +- Keep headings bracketed. +- Keep footer references at the bottom of the file. +- Omit empty sections. +- Prefer compare links for every release except the oldest documented one. diff --git a/.agents/skills/changelog-generator/scripts/bootstrap.php b/.agents/skills/changelog-generator/scripts/bootstrap.php new file mode 100644 index 0000000..8d55b40 --- /dev/null +++ b/.agents/skills/changelog-generator/scripts/bootstrap.php @@ -0,0 +1,172 @@ + $command + * @param string $cwd + */ +function changelogSkillRun(array $command, string $cwd): string +{ + $escapedCommand = implode(' ', array_map(static fn(string $part): string => escapeshellarg($part), $command)); + + $process = proc_open($escapedCommand, [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes, $cwd,); + + if (! is_resource($process)) { + throw new RuntimeException('Unable to execute command: ' . $escapedCommand); + } + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if (0 !== $exitCode) { + throw new RuntimeException(trim($stderr) ?: ('Command failed: ' . $escapedCommand)); + } + + return trim((string) $stdout); +} + +function changelogSkillNormalizeVersion(string $value): string +{ + return ltrim(trim($value, "[] \t\n\r\0\x0B"), 'v'); +} + +function changelogSkillIsReleaseTag(string $tag): bool +{ + return 1 === preg_match('/^v?\d+\.\d+\.\d+(?:[-.][A-Za-z0-9.-]+)?$/', $tag); +} + +/** + * @param string $projectRoot + * + * @return array{ + * changelog_exists: bool, + * changelog_has_content: bool, + * unreleased_present: bool, + * documented_versions: list, + * latest_documented_version: string|null + * } + */ +function changelogSkillReadChangelogState(string $projectRoot): array +{ + $changelogPath = $projectRoot . '/CHANGELOG.md'; + + if (! is_file($changelogPath)) { + return [ + 'changelog_exists' => false, + 'changelog_has_content' => false, + 'unreleased_present' => false, + 'documented_versions' => [], + 'latest_documented_version' => null, + ]; + } + + $contents = trim((string) file_get_contents($changelogPath)); + $documentedVersions = []; + $unreleasedPresent = false; + + preg_match_all( + '/^##\s+\[?(Unreleased|v?\d+\.\d+\.\d+(?:[-.][A-Za-z0-9.-]+)?)\]?(?:\s+-\s+[^\r\n]+)?$/m', + $contents, + $matches, + ); + + foreach ($matches[1] as $heading) { + if ('Unreleased' === $heading) { + $unreleasedPresent = true; + continue; + } + + $documentedVersions[] = changelogSkillNormalizeVersion($heading); + } + + return [ + 'changelog_exists' => true, + 'changelog_has_content' => '' !== $contents, + 'unreleased_present' => $unreleasedPresent, + 'documented_versions' => array_values(array_unique($documentedVersions)), + 'latest_documented_version' => $documentedVersions[0] ?? null, + ]; +} + +/** + * @param string $projectRoot + * + * @return list + */ +function changelogSkillReadTags(string $projectRoot): array +{ + $output = changelogSkillRun(['git', 'tag', '--sort=version:refname'], $projectRoot); + + if ('' === $output) { + return []; + } + + $tags = []; + + foreach (preg_split('/\R/', $output) ?: [] as $tag) { + $tag = trim($tag); + + if ('' === $tag || ! changelogSkillIsReleaseTag($tag)) { + continue; + } + + $tags[] = [ + 'tag' => $tag, + 'version' => changelogSkillNormalizeVersion($tag), + ]; + } + + return $tags; +} + +function changelogSkillEmitJson(array $payload): void +{ + echo json_encode($payload, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) . \PHP_EOL; +} diff --git a/.agents/skills/changelog-generator/scripts/changelog-state.php b/.agents/skills/changelog-generator/scripts/changelog-state.php new file mode 100644 index 0000000..9ac8063 --- /dev/null +++ b/.agents/skills/changelog-generator/scripts/changelog-state.php @@ -0,0 +1,55 @@ +#!/usr/bin/env php + $tagInfo) { + if (isset($documentedVersions[$tagInfo['version']])) { + continue; + } + + $undocumentedTags[] = $tagInfo; + $releaseRanges[] = [ + 'version' => $tagInfo['version'], + 'from_tag' => $tags[$index - 1]['tag'] ?? null, + 'to_tag' => $tagInfo['tag'], + ]; +} + +$latestTag = [] === $tags ? null : $tags[array_key_last($tags)]; +$latestDocumentedTag = null; + +if (null !== $changelogState['latest_documented_version']) { + foreach ($tags as $tagInfo) { + if ($tagInfo['version'] === $changelogState['latest_documented_version']) { + $latestDocumentedTag = $tagInfo; + } + } +} + +changelogSkillEmitJson([ + 'project_root' => $projectRoot, + 'changelog_exists' => $changelogState['changelog_exists'], + 'changelog_has_content' => $changelogState['changelog_has_content'], + 'unreleased_present' => $changelogState['unreleased_present'], + 'documented_versions' => $changelogState['documented_versions'], + 'latest_documented_version' => $changelogState['latest_documented_version'], + 'latest_documented_tag' => $latestDocumentedTag['tag'] ?? null, + 'all_tags' => $tags, + 'undocumented_tags' => $undocumentedTags, + 'release_ranges' => $releaseRanges, + 'suggested_unreleased_base' => $latestTag['tag'] ?? null, + 'needs_bootstrap' => ! $changelogState['changelog_exists'] || ! $changelogState['changelog_has_content'], + 'needs_backfill' => [] !== $undocumentedTags, +]); diff --git a/.agents/skills/changelog-generator/scripts/diff-inventory.php b/.agents/skills/changelog-generator/scripts/diff-inventory.php new file mode 100644 index 0000000..e05a8e8 --- /dev/null +++ b/.agents/skills/changelog-generator/scripts/diff-inventory.php @@ -0,0 +1,133 @@ +#!/usr/bin/env php + 4) { + fwrite(\STDERR, "Usage: php diff-inventory.php [repo-path]\n"); + exit(1); +} + +$fromRef = $argv[1]; +$toRef = $argv[2]; +$projectRoot = changelogSkillProjectRoot($argv[3] ?? null); +changelogSkillRequireAutoload($projectRoot); + +$nameStatusOutput = changelogSkillRun(['git', 'diff', '--name-status', $fromRef, $toRef], $projectRoot); +$numStatOutput = changelogSkillRun(['git', 'diff', '--numstat', $fromRef, $toRef], $projectRoot); + +$lineCounts = []; + +foreach (preg_split('/\R/', $numStatOutput) ?: [] as $line) { + if ('' === trim($line)) { + continue; + } + + $parts = preg_split('/\t+/', trim($line)); + + if (! is_array($parts) || count($parts) < 3) { + continue; + } + + $path = $parts[2]; + $lineCounts[$path] = [ + 'added_lines' => is_numeric($parts[0]) ? (int) $parts[0] : null, + 'deleted_lines' => is_numeric($parts[1]) ? (int) $parts[1] : null, + ]; +} + +$files = []; + +foreach (preg_split('/\R/', $nameStatusOutput) ?: [] as $line) { + if ('' === trim($line)) { + continue; + } + + $parts = preg_split('/\t+/', trim($line)); + + if (! is_array($parts) || count($parts) < 2) { + continue; + } + + $status = $parts[0]; + $oldPath = null; + $path = $parts[1]; + + if (str_starts_with($status, 'R') || str_starts_with($status, 'C')) { + $oldPath = $parts[1] ?? null; + $path = $parts[2] ?? $path; + } + + $signals = []; + $priority = 0; + + foreach ([ + ['composer.json', 'dependency-surface', 100], + ['README.md', 'top-level-doc', 80], + ['CHANGELOG.md', 'changelog', 80], + ['src/Command/', 'command-surface', 95], + ['src/', 'php-surface', 70], + ['bin/', 'cli-entrypoint', 90], + ['docs/', 'documentation', 60], + ['.github/workflows/', 'workflow', 85], + ['resources/github-actions/', 'workflow-template', 85], + ['tests/', 'tests', 20], + ] as [$prefix, $signal, $score]) { + if ($path === $prefix || str_starts_with($path, $prefix)) { + $signals[] = $signal; + $priority = max($priority, $score); + } + } + + if ([] === $signals) { + $signals[] = 'supporting-file'; + $priority = 10; + } + + $files[] = [ + 'path' => $path, + 'old_path' => $oldPath, + 'status' => $status, + 'signals' => $signals, + 'priority' => $priority, + 'added_lines' => $lineCounts[$path]['added_lines'] ?? null, + 'deleted_lines' => $lineCounts[$path]['deleted_lines'] ?? null, + ]; +} + +usort( + $files, + static function (array $left, array $right): int { + $priorityComparison = $right['priority'] <=> $left['priority']; + + if (0 !== $priorityComparison) { + return $priorityComparison; + } + + return $left['path'] <=> $right['path']; + }, +); + +changelogSkillEmitJson([ + 'project_root' => $projectRoot, + 'range' => [ + 'from' => $fromRef, + 'to' => $toRef, + ], + 'counts' => [ + 'files' => count($files), + 'added' => count(array_filter($files, static fn(array $file): bool => str_starts_with($file['status'], 'A'))), + 'modified' => count( + array_filter($files, static fn(array $file): bool => str_starts_with($file['status'], 'M')) + ), + 'deleted' => count(array_filter($files, static fn(array $file): bool => str_starts_with($file['status'], 'D'))), + 'renamed' => count(array_filter($files, static fn(array $file): bool => str_starts_with($file['status'], 'R'))), + ], + 'priority_paths' => array_values(array_map( + static fn(array $file): string => $file['path'], + array_slice($files, 0, 15), + )), + 'files' => $files, +]); diff --git a/.agents/skills/github-pull-request/SKILL.md b/.agents/skills/github-pull-request/SKILL.md index 4f4f2c9..b564d3d 100644 --- a/.agents/skills/github-pull-request/SKILL.md +++ b/.agents/skills/github-pull-request/SKILL.md @@ -21,10 +21,28 @@ 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` whenever a change affects testable behavior, public APIs, documented usage, or the change history — including adding, modifying, or removing features, bug fixes, or contract changes. - 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 + +For any change that is user-visible or affects behavior, use `changelog-generator` to update CHANGELOG.md. + +1. Use the `changelog-generator` skill to analyze code changes since last release +2. Add entries under the [Unreleased] section only for PR-specific changes +3. Write concise, specific, user-facing descriptions (avoid implementation details) following the skill's quality rules +4. Include PR reference when applicable: "Added changelog automation (#40)" +5. Group entries by type if applicable (e.g., Added, Fixed, Changed, Breaking) + +**Rules**: + +- Do not duplicate existing entries +- Do not move or modify past releases +- Every eligible PR must include its changelog entry before merge + +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/.github/wiki b/.github/wiki index ec27577..1256bfb 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit ec27577fccbe944dc811db81944e1f2d54be93a9 +Subproject commit 1256bfb0f0ab4d8c8e5981b879d2fc92faae769f diff --git a/.github/workflows/changelog-bump.yml b/.github/workflows/changelog-bump.yml new file mode 100644 index 0000000..450cc01 --- /dev/null +++ b/.github/workflows/changelog-bump.yml @@ -0,0 +1,61 @@ +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 + 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 + 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..0c98c06 --- /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..2351b97 --- /dev/null +++ b/.github/workflows/require-changelog.yml @@ -0,0 +1,63 @@ +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 + 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}"' 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/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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..981a565 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,173 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Added `phly/keep-a-changelog` integration with `changelog:init`, `changelog:check`, and reusable `changelog-bump`, `require-changelog`, and `release` workflows to bootstrap `CHANGELOG.md`, validate `Unreleased`, and publish matching GitHub release notes (#40) +- Added reusable `auto-assign` and `label-sync` workflows to assign pull request authors and copy linked issue labels onto pull requests (#35) +- Added the `changelog-generator` skill and bundled PHP helpers to inspect changelog state and prioritize diff review before drafting entries (#40) + +### Changed + +- Changed `dev-tools:sync`, the README, and Sphinx usage docs to install and document changelog automation assets for consumer repositories (#40) +- Changed the reports, tests, and wiki workflows to install dependencies with `php-actions/composer` and lockfile-based Composer caching (#35) +- Changed the reports and wiki workflows to keep Composer plugins enabled when dev-tools commands must run in CI (#39) +- Changed changelog bootstrap and validation workflows to run Composer-based dev-tools commands on PHP 8.3 instead of legacy script shims (#40) +- Changed changelog guidance to derive entries from code diffs and append related pull request references in the format `(#123)` when a matching PR exists (#40) + +## [1.4.0] - 2026-04-11 + +### Added + +- Added `CoverageSummary` and `CoverageSummaryLoader` so PHPUnit coverage data can be reused programmatically in tooling and reports + +### Changed + +- Changed PHPUnit coverage validation to use the in-repository summary loader instead of an external checker + +### Fixed + +- Fixed Symfony component constraints to support version 8.0 + +## [1.3.0] - 2026-04-11 + +### Added + +- Added `GitAttributesCommand` and supporting reader, merger, and writer services to keep `export-ignore` rules synchronized with packaged files +- Added export-ignore coverage for license templates and `context7.json` in packaged archives + +### Changed + +- Changed Git attribute synchronization into a dedicated command instead of implicit sync logic + +## [1.2.1] - 2026-04-10 + +### Added + +- Added fuller PHPDoc coverage for license generation services and interfaces (#26) + +### Changed + +- Changed `.gitattributes` to export the packaging metadata introduced with license generation (#26) + +## [1.2.0] - 2026-04-10 + +### Added + +- Added `CopyLicenseCommand`, `DependenciesCommand`, and `SkillsCommand` to generate LICENSE files, audit Composer dependencies, and sync packaged agent skills into consumer repositories +- Added bundled license templates plus Fast Forward PHPDoc and PHPUnit skill packages for synced projects + +### Changed + +- Changed installation and consumer automation docs to cover dependency analysis, skill synchronization, and branch and pull request workflow guidance + +### Fixed + +- Fixed dependency analysis reporting for missing and unused Composer packages + +## [1.1.0] - 2026-04-09 + +### Added + +- Added `.gitignore` synchronization with classifier, merger, reader, and writer services for consumer repositories +- Added `ECSConfig` and `RectorConfig` extension points so consumers can override default tool configuration +- Added Fast Forward skills for GitHub issue, Sphinx docs, README, and PHPUnit workflows + +### Changed + +- Changed the installation flow into `dev-tools:sync`, which synchronizes scripts, GitHub workflow templates, `.editorconfig`, Dependabot config, and the repository wiki submodule +- Changed command abstractions and Composer plugin wiring to simplify CLI orchestration and consumer setup + +### Fixed + +- Fixed the reports workflow trigger, PHPDoc cleanup, skill file endings, and `.editorconfig` synchronization + +## [1.0.0] - 2026-04-08 + +### Added + +- Initial public release of `fast-forward/dev-tools` as a Composer plugin that exposes a unified `dev-tools` workflow for tests, code style, refactoring, PHPDoc, reports, and wiki automation +- Added PHPUnit desktop notification support through `DevToolsExtension`, `ByPassfinalsStartedSubscriber`, `JoliNotifExecutionFinishedSubscriber`, and bundled notifier assets +- Added expanded Sphinx guides, FAQ content, project links, and README badges for installation, configuration, and usage +- Added Dependabot and funding templates plus the phpDocumentor bootstrap template and Composer changelog plugin to the packaged tooling + +### Changed + +- Changed `install` into `dev-tools:sync` and updated Composer hooks to synchronize scripts after install and update +- Changed `DocsCommand` to accept a custom template path and include standard issue markers in generated API docs +- Changed `TestsCommand` to accept `--filter` for targeted PHPUnit runs +- Changed workflow templates, GitHub Pages metadata, and package dependencies to support richer reports and consumer automation + +### Fixed + +- Fixed GitHub Pages metadata, workflow PHP extension declarations, wiki submodule path handling, and phpdoc command arguments + +## [1.2.2] - 2026-03-26 + +### Added + +- Added `install` command to synchronize dev-tools scripts, reusable GitHub workflow-call templates, `.editorconfig`, and GrumPHP defaults into consumer repositories + +### Changed + +- Changed Composer plugin hooks to run synchronization after install and update instead of package-specific events +- Changed PHPUnit, reports, and wiki workflows to call the package's shared GitHub Actions templates +- Changed command and Rector/PHPDoc internals to align with the install-based synchronization flow + +### Removed + +- Removed `InstallScriptsCommand` and `ScriptsInstallerTrait` in favor of the unified install command + +## [1.0.4] - 2026-03-26 + +### Changed + +- Changed `DocsCommand` to resolve configuration files relative to the project root, including consumer-friendly relative paths + +## [1.0.3] - 2026-03-26 + +### Added + +- Added package name validation to install scripts before updating consumer repositories + +## [1.0.2] - 2026-03-26 + +### Changed + +- Changed Composer plugin metadata to declare `composer/composer` as a required dependency during installation + +## [1.0.1] - 2026-03-26 + +### Added + +- Added `InstallScriptsCommand` and `WikiCommand` to install dev-tools scripts and publish repository wiki content from the package +- Added README, Sphinx docs, and phpDocumentor configuration to document installation, commands, and workflows +- Added automated tests for commands, Composer plugin integration, and the custom Rector rules shipped with dev-tools + +### Changed + +- Changed core commands and Composer plugin wiring to resolve project paths, autoload files, and generated reports more reliably +- Changed tests, reports, and wiki workflows to run against the packaged commands and publish their artifacts consistently + +### Fixed + +- Fixed command argument handling, bin registration, path resolution, deploy wiring, and coverage configuration for the initial packaged release + +[unreleased]: https://github.com/php-fast-forward/dev-tools/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/php-fast-forward/dev-tools/compare/v1.3.0...v1.4.0 +[1.3.0]: https://github.com/php-fast-forward/dev-tools/compare/v1.2.1...v1.3.0 +[1.2.1]: https://github.com/php-fast-forward/dev-tools/compare/v1.2.0...v1.2.1 +[1.2.0]: https://github.com/php-fast-forward/dev-tools/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/php-fast-forward/dev-tools/compare/v1.2.2...v1.0.0 +[1.2.2]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.4...v1.2.2 +[1.0.4]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/php-fast-forward/dev-tools/releases/tag/v1.0.1 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 b65b5c2..760ef45 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "icanhazstring/composer-unused": "^0.9.6", "jolicode/jolinotif": "^3.3", "nikic/php-parser": "^5.7", + "phly/keep-a-changelog": "^2.13", "php-di/php-di": "^7.1", "phpdocumentor/shim": "^3.9", "phpro/grumphp": "^2.19", @@ -73,6 +74,7 @@ "config": { "allow-plugins": { "ergebnis/composer-normalize": true, + "php-http/discovery": true, "phpdocumentor/shim": true, "phpro/grumphp": true, "pyrech/composer-changelogs": true diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 736ffc2..c5b503e 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\Console\Command\DependenciesCommand`` - ``dependencies`` - Reports missing and unused Composer dependencies. + * - ``FastForward\DevTools\Console\Command\ChangelogInitCommand`` + - ``changelog:init`` + - Bootstraps ``CHANGELOG.md`` and keep-a-changelog configuration. + * - ``FastForward\DevTools\Console\Command\ChangelogCheckCommand`` + - ``changelog:check`` + - Verifies that the ``Unreleased`` section contains meaningful notes. * - ``FastForward\DevTools\Console\Command\DocsCommand`` - ``docs`` - Builds the HTML documentation site. @@ -47,8 +53,8 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch. - Synchronizes packaged agent skills into ``.agents/skills``. * - ``FastForward\DevTools\Console\Command\SyncCommand`` - ``dev-tools:sync`` - - Synchronizes consumer-facing scripts, automation assets, and packaged - skills. + - Synchronizes consumer-facing scripts, automation assets, changelog + workflows, and packaged skills. * - ``FastForward\DevTools\Console\Command\GitIgnoreCommand`` - ``gitignore`` - Merges and synchronizes .gitignore files. 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..6c002b6 --- /dev/null +++ b/docs/usage/changelog-management.rst @@ -0,0 +1,67 @@ +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: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..781846f --- /dev/null +++ b/src/Changelog/BootstrapResult.php @@ -0,0 +1,38 @@ + + * @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 +{ + /** + * 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, + public bool $changelogCreated, + public bool $unreleasedCreated, + ) {} +} diff --git a/src/Changelog/Bootstrapper.php b/src/Changelog/Bootstrapper.php new file mode 100644 index 0000000..655a9aa --- /dev/null +++ b/src/Changelog/Bootstrapper.php @@ -0,0 +1,117 @@ + + * @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 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 +{ + private const string STANDARD_INTRODUCTION = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"; + + private const string UNRELEASED_SECTION = "## [Unreleased]\n\n"; + + /** + * 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 = 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 + { + $configPath = Path::join($workingDirectory, '.keep-a-changelog.ini'); + $changelogPath = Path::join($workingDirectory, 'CHANGELOG.md'); + + $configCreated = false; + $changelogCreated = false; + $unreleasedCreated = false; + + if (! $this->filesystem->exists($configPath)) { + $this->filesystem->dumpFile($configPath, $this->configRenderer->render()); + $configCreated = true; + } + + if (! $this->filesystem->exists($changelogPath)) { + $this->filesystem->dumpFile($changelogPath, $this->historyGenerator->generate($workingDirectory)); + $changelogCreated = true; + + return new BootstrapResult($configCreated, $changelogCreated, $unreleasedCreated); + } + + $contents = $this->filesystem->readFile($changelogPath); + + if (! str_contains($contents, '## [Unreleased]') && ! str_contains($contents, '## Unreleased - ')) { + $this->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 + { + $updatedContents = str_replace( + self::STANDARD_INTRODUCTION, + self::STANDARD_INTRODUCTION . self::UNRELEASED_SECTION, + $contents + ); + + if ($updatedContents !== $contents) { + return $updatedContents; + } + + $firstSecondaryHeadingOffset = strpos($contents, "\n## "); + + if (false === $firstSecondaryHeadingOffset) { + return rtrim($contents) . "\n\n" . self::UNRELEASED_SECTION; + } + + return substr($contents, 0, $firstSecondaryHeadingOffset + 1) + . self::UNRELEASED_SECTION + . substr($contents, $firstSecondaryHeadingOffset + 1); + } +} diff --git a/src/Changelog/BootstrapperInterface.php b/src/Changelog/BootstrapperInterface.php new file mode 100644 index 0000000..3ba7e77 --- /dev/null +++ b/src/Changelog/BootstrapperInterface.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; + +/** + * 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 + */ + public function bootstrap(string $workingDirectory): BootstrapResult; +} diff --git a/src/Changelog/CommitClassifier.php b/src/Changelog/CommitClassifier.php new file mode 100644 index 0000000..46198b7 --- /dev/null +++ b/src/Changelog/CommitClassifier.php @@ -0,0 +1,92 @@ + + * @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 +{ + /** + * Classifies a commit subject into a changelog section based on conventional prefixes and keywords. + * + * @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 + { + $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'; + } + + /** + * 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 normalized commit subject + */ + 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..dffdec0 --- /dev/null +++ b/src/Changelog/CommitClassifierInterface.php @@ -0,0 +1,56 @@ + + * @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. + * + * 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 +{ + /** + * 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. + * + * @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; + + /** + * 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 normalized commit subject + */ + public function normalize(string $subject): string; +} diff --git a/src/Changelog/GitProcessRunner.php b/src/Changelog/GitProcessRunner.php new file mode 100644 index 0000000..02f27b7 --- /dev/null +++ b/src/Changelog/GitProcessRunner.php @@ -0,0 +1,45 @@ + + * @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 +{ + /** + * Executes a git command in the specified working directory and returns the trimmed output. + * + * @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 + { + $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..52377bb --- /dev/null +++ b/src/Changelog/GitProcessRunnerInterface.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; + +/** + * 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. + * + * 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 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 new file mode 100644 index 0000000..fe39a6b --- /dev/null +++ b/src/Changelog/GitReleaseCollector.php @@ -0,0 +1,137 @@ + + * @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 +{ + /** + * 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() + ) {} + + /** + * 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}> list of releases with version, tag, date, and associated commit subjects + */ + 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; + } + + /** + * Collects commit subjects for a given git range in the specified working directory. + * + * @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 + { + $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))); + } + + /** + * 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 True if the subject should be ignored (e.g., empty, merge commits, wiki updates); false otherwise. + */ + 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..8ff37af --- /dev/null +++ b/src/Changelog/GitReleaseCollectorInterface.php @@ -0,0 +1,41 @@ + + * @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. + * + * 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 +{ + /** + * 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}> 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 new file mode 100644 index 0000000..47dfd4d --- /dev/null +++ b/src/Changelog/HistoryGenerator.php @@ -0,0 +1,99 @@ + + * @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 Throwable; + +use function array_values; + +/** + * Converts release metadata and commit subjects into a rendered changelog file. + */ +final readonly class HistoryGenerator implements HistoryGeneratorInterface +{ + /** + * 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 + * @param GitProcessRunnerInterface $gitProcessRunner git process runner used to resolve repository metadata required by the rendered changelog footer + */ + public function __construct( + private GitReleaseCollectorInterface $gitReleaseCollector = new GitReleaseCollector(), + private CommitClassifierInterface $commitClassifier = new CommitClassifier(), + private MarkdownRenderer $markdownRenderer = new MarkdownRenderer(), + private GitProcessRunnerInterface $gitProcessRunner = new GitProcessRunner(), + ) {} + + /** + * @param string $workingDirectory + * + * @return string + */ + public function generate(string $workingDirectory): string + { + $releases = []; + $repositoryUrl = $this->resolveRepositoryUrl($workingDirectory); + + 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'], + 'tag' => $release['tag'], + 'date' => $release['date'], + 'entries' => $entries, + ]; + } + + return $this->markdownRenderer->render($releases, $repositoryUrl); + } + + /** + * @param string $workingDirectory + * + * @return string|null + */ + private function resolveRepositoryUrl(string $workingDirectory): ?string + { + try { + $repositoryUrl = $this->gitProcessRunner->run([ + 'git', + 'config', + '--get', + 'remote.origin.url', + ], $workingDirectory); + } catch (Throwable) { + return null; + } + + return '' === $repositoryUrl ? null : $repositoryUrl; + } +} diff --git a/src/Changelog/HistoryGeneratorInterface.php b/src/Changelog/HistoryGeneratorInterface.php new file mode 100644 index 0000000..e762228 --- /dev/null +++ b/src/Changelog/HistoryGeneratorInterface.php @@ -0,0 +1,42 @@ + + * @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. + * + * 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 +{ + /** + * 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 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 new file mode 100644 index 0000000..f556221 --- /dev/null +++ b/src/Changelog/KeepAChangelogConfigRenderer.php @@ -0,0 +1,46 @@ + + * @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 +{ + /** + * 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 + { + 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..deb015a --- /dev/null +++ b/src/Changelog/MarkdownRenderer.php @@ -0,0 +1,190 @@ + + * @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_reverse; +use function array_values; +use function explode; +use function implode; +use function rtrim; +use function str_ends_with; +use function substr; +use function trim; + +/** + * 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']; + + private const string INTRODUCTION = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)."; + + /** + * Renders the changelog markdown content. + * + * @param list>, tag?: string}> $releases list of releases with their version, date, entries, and optional tag + * @param string|null $repositoryUrl repository URL used to build footer references + * + * @return string the generated changelog markdown content + */ + public function render(array $releases, ?string $repositoryUrl = null): string + { + $orderedReleases = array_values(array_reverse($releases)); + $lines = explode("\n", self::INTRODUCTION); + + $lines = [...$lines, '', '## [Unreleased]', '']; + + foreach ($orderedReleases as $release) { + $lines = [ + ...$lines, + ...$this->renderSection($release['version'], $release['date'], $release['entries']), + ]; + } + + $references = $this->renderReferences($orderedReleases, $repositoryUrl); + + if ([] !== $references) { + $lines = [...$lines, ...$references]; + } + + return implode("\n", $lines) . "\n"; + } + + /** + * 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 the rendered lines for the release section + */ + private function renderSection(string $version, string $date, array $entries): array + { + $lines = ['## [' . $version . '] - ' . $date, '']; + + foreach (self::SECTION_ORDER as $section) { + $sectionEntries = $entries[$section] ?? []; + + if ([] === $sectionEntries) { + continue; + } + + $lines[] = '### ' . $section; + $lines[] = ''; + + foreach ($sectionEntries as $entry) { + $lines[] = '- ' . $entry; + } + + $lines[] = ''; + } + + return $lines; + } + + /** + * @param list>, tag?: string}> $orderedReleases + * @param ?string $repositoryUrl + * + * @return list + */ + private function renderReferences(array $orderedReleases, ?string $repositoryUrl): array + { + $normalizedRepositoryUrl = $this->normalizeRepositoryUrl($repositoryUrl); + + if (null === $normalizedRepositoryUrl || [] === $orderedReleases) { + return []; + } + + $references = [ + \sprintf( + '[unreleased]: %s/compare/%s...HEAD', + $normalizedRepositoryUrl, + $this->resolveTag($orderedReleases[0]), + ), + ]; + + foreach ($orderedReleases as $index => $release) { + $references[] = isset($orderedReleases[$index + 1]) + ? \sprintf( + '[%s]: %s/compare/%s...%s', + $release['version'], + $normalizedRepositoryUrl, + $this->resolveTag($orderedReleases[$index + 1]), + $this->resolveTag($release), + ) + : \sprintf( + '[%s]: %s/releases/tag/%s', + $release['version'], + $normalizedRepositoryUrl, + $this->resolveTag($release), + ); + } + + return ['', ...$references]; + } + + /** + * @param array{version: string, date: string, entries: array>, tag?: string} $release + * + * @return string + */ + private function resolveTag(array $release): string + { + return $release['tag'] ?? 'v' . $release['version']; + } + + /** + * @param string|null $repositoryUrl + * + * @return string|null + */ + private function normalizeRepositoryUrl(?string $repositoryUrl): ?string + { + if (null === $repositoryUrl) { + return null; + } + + $repositoryUrl = trim($repositoryUrl); + + if ('' === $repositoryUrl) { + return null; + } + + if (1 === preg_match('~^git@(?[^:]+):(?.+)$~', $repositoryUrl, $matches)) { + $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path']; + } + + if (1 === preg_match('~^ssh://git@(?[^/]+)/(?.+)$~', $repositoryUrl, $matches)) { + $repositoryUrl = 'https://' . $matches['host'] . '/' . $matches['path']; + } + + if (str_ends_with($repositoryUrl, '.git')) { + $repositoryUrl = substr($repositoryUrl, 0, -4); + } + + return rtrim($repositoryUrl, '/'); + } +} diff --git a/src/Changelog/UnreleasedEntryChecker.php b/src/Changelog/UnreleasedEntryChecker.php new file mode 100644 index 0000000..517a427 --- /dev/null +++ b/src/Changelog/UnreleasedEntryChecker.php @@ -0,0 +1,125 @@ + + * @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 +{ + /** + * Constructs a new UnreleasedEntryChecker. + * + * @param GitProcessRunnerInterface $gitProcessRunner the Git process runner + */ + public function __construct( + private GitProcessRunnerInterface $gitProcessRunner = new GitProcessRunner() + ) {} + + /** + * 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 true if there are pending unreleased entries, false otherwise + */ + 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)); + } + + /** + * Extracts unreleased entries from the given changelog content. + * + * @param string $contents the changelog content + * + * @return list the list of unreleased entries + */ + private function extractEntries(string $contents): array + { + if ( + 0 === preg_match( + '/^## (?:(?:\[Unreleased\])|(?:Unreleased\s+-\s+.+))\s*(?:\R(?.*?))?(?=^##\s|\z)/ms', + $contents, + $matches + ) + ) { + return []; + } + + $lines = preg_split('/\R/', trim($matches['body'] ?? '')); + + 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..53a5e77 --- /dev/null +++ b/src/Changelog/UnreleasedEntryCheckerInterface.php @@ -0,0 +1,42 @@ + + * @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. + * + * 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 +{ + /** + * 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 true if there are pending unreleased entries, false otherwise + */ + public function hasPendingChanges(string $workingDirectory, ?string $againstReference = null): bool; +} diff --git a/src/Console/Command/ChangelogCheckCommand.php b/src/Console/Command/ChangelogCheckCommand.php new file mode 100644 index 0000000..9bacfb2 --- /dev/null +++ b/src/Console/Command/ChangelogCheckCommand.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\Console\Command; + +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 +{ + /** + * 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( + private readonly UnreleasedEntryCheckerInterface $unreleasedEntryChecker, + Filesystem $filesystem, + ) { + 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.', + ); + } + + /** + * 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 exit code indicating success (0) or failure (1) + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $hasPendingChanges = $this->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/Console/Command/ChangelogInitCommand.php b/src/Console/Command/ChangelogInitCommand.php new file mode 100644 index 0000000..91d5b3c --- /dev/null +++ b/src/Console/Command/ChangelogInitCommand.php @@ -0,0 +1,84 @@ + + * @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\Console\Command; + +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( + private readonly BootstrapperInterface $bootstrapper, + Filesystem $filesystem, + ) { + 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 + ->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/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index b80de23..5db3e60 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -34,8 +34,8 @@ */ #[AsCommand( name: 'dev-tools:sync', - description: 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and .gitattributes in the root project.', - help: 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, ensures .editorconfig is present and up to date, ' + description: 'Installs and synchronizes dev-tools scripts, GitHub Actions reusable workflows, changelog assets, .editorconfig, and .gitattributes in the root project.', + help: '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.' )] final class SyncCommand extends AbstractCommand @@ -64,6 +64,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; } diff --git a/src/ServiceProvider/DevToolsServiceProvider.php b/src/ServiceProvider/DevToolsServiceProvider.php index d808651..1788fb9 100644 --- a/src/ServiceProvider/DevToolsServiceProvider.php +++ b/src/ServiceProvider/DevToolsServiceProvider.php @@ -44,6 +44,10 @@ use FastForward\DevTools\License\TemplateLoaderInterface; use FastForward\DevTools\License\TemplateLoader; use Composer\Plugin\Capability\CommandProvider; +use FastForward\DevTools\Changelog\Bootstrapper; +use FastForward\DevTools\Changelog\BootstrapperInterface; +use FastForward\DevTools\Changelog\UnreleasedEntryChecker; +use FastForward\DevTools\Changelog\UnreleasedEntryCheckerInterface; use FastForward\DevTools\Console\CommandLoader\DevToolsCommandLoader; use FastForward\DevTools\GitAttributes\Merger as GitAttributesMerger; use FastForward\DevTools\GitAttributes\MergerInterface as GitAttributesMergerInterface; @@ -109,6 +113,10 @@ public function getFactories(): array LicenseReaderInterface::class => get(LicenseReader::class), ResolverInterface::class => get(Resolver::class), TemplateLoaderInterface::class => get(TemplateLoader::class), + + // Changelog + BootstrapperInterface::class => get(Bootstrapper::class), + UnreleasedEntryCheckerInterface::class => get(UnreleasedEntryChecker::class), ]; } 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 new file mode 100644 index 0000000..37aa4b8 --- /dev/null +++ b/tests/Changelog/BootstrapperTest.php @@ -0,0 +1,173 @@ + + * @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 Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Filesystem\Filesystem; + +#[CoversClass(Bootstrapper::class)] +#[UsesClass(BootstrapResult::class)] +#[UsesClass(KeepAChangelogConfigRenderer::class)] +final class BootstrapperTest extends TestCase +{ + use ProphecyTrait; + + private ObjectProphecy $filesystem; + + private ObjectProphecy $historyGenerator; + + private ObjectProphecy $configRenderer; + + private string $workingDirectory; + + private Bootstrapper $bootstrapper; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $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(); + } + + /** + * @return void + */ + #[Test] + public function bootstrapWillCreateMissingConfigAndChangelogFiles(): void + { + $this->givenFilesDoNotExist(); + + $result = $this->bootstrapper->bootstrap($this->workingDirectory); + + self::assertTrue($result->configCreated); + self::assertTrue($result->changelogCreated); + self::assertFalse($result->unreleasedCreated); + } + + /** + * @return void + */ + #[Test] + public function bootstrapWillRestoreMissingUnreleasedSection(): void + { + $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.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [1.0.0] - 2026-04-08\n\n### Added\n\n- Initial release.\n" + ) + ->shouldBeCalled(); + $this->filesystem->dumpFile( + '/tmp/fake-dir/CHANGELOG.md', + Argument::that(fn(string $content): bool => str_contains($content, '## [Unreleased]')) + )->shouldBeCalled(); + + $result = $this->bootstrapper->bootstrap($this->workingDirectory); + + self::assertFalse($result->configCreated); + self::assertFalse($result->changelogCreated); + self::assertTrue($result->unreleasedCreated); + } + + /** + * @return void + */ + #[Test] + public function bootstrapWillRestoreMissingUnreleasedSectionForExistingCustomIntro(): void + { + $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( + '/tmp/fake-dir/CHANGELOG.md', + Argument::that( + fn(string $content): bool => str_contains( + $content, + "Project-specific introduction.\n\n## [Unreleased]\n\n## [1.0.0] - 2026-04-08" + ) + ) + )->shouldBeCalled(); + + $result = $this->bootstrapper->bootstrap($this->workingDirectory); + + self::assertFalse($result->configCreated); + self::assertFalse($result->changelogCreated); + self::assertTrue($result->unreleasedCreated); + } +} 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..229048f --- /dev/null +++ b/tests/Changelog/HistoryGeneratorTest.php @@ -0,0 +1,115 @@ + + * @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\GitProcessRunnerInterface; +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; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $gitProcessRunner; + + private HistoryGenerator $historyGenerator; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->gitReleaseCollector = $this->prophesize(GitReleaseCollectorInterface::class); + $this->commitClassifier = $this->prophesize(CommitClassifierInterface::class); + $this->gitProcessRunner = $this->prophesize(GitProcessRunnerInterface::class); + $this->historyGenerator = new HistoryGenerator( + $this->gitReleaseCollector->reveal(), + $this->commitClassifier->reveal(), + new MarkdownRenderer(), + $this->gitProcessRunner->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->gitProcessRunner->run(['git', 'config', '--get', 'remote.origin.url'], '/tmp/project') + ->willReturn('git@github.com:php-fast-forward/dev-tools.git'); + $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]', $markdown); + self::assertStringContainsString('## [1.0.0] - 2026-04-08', $markdown); + self::assertStringContainsString('- Add bootstrap', $markdown); + self::assertStringContainsString('- Validate changelog', $markdown); + self::assertStringContainsString( + '[unreleased]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.0...HEAD', + $markdown + ); + self::assertStringContainsString( + '[1.0.0]: https://github.com/php-fast-forward/dev-tools/releases/tag/v1.0.0', + $markdown + ); + } +} 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..f29e9a6 --- /dev/null +++ b/tests/Changelog/MarkdownRendererTest.php @@ -0,0 +1,209 @@ + + * @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 + ); + self::assertStringContainsString( + 'The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),', + $output + ); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeUnreleasedSection(): void + { + $output = $this->renderer->render([]); + + self::assertStringContainsString('## [Unreleased]', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeAllSectionTypesWhenEntriesExist(): void + { + $output = $this->renderer->render([ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [ + 'Added' => ['Feature A'], + 'Changed' => ['Feature B'], + 'Deprecated' => ['Feature C'], + 'Removed' => ['Feature D'], + 'Fixed' => ['Feature E'], + 'Security' => ['Feature F'], + ], + ], + ]); + + 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 renderWillOmitEmptySectionPlaceholders(): void + { + $releases = [ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [], + ], + ]; + + $output = $this->renderer->render($releases); + + self::assertStringNotContainsString('- Nothing.', $output); + self::assertStringContainsString('## [1.0.0] - 2026-04-01', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillAppendOfficialFooterReferencesWhenRepositoryUrlIsKnown(): void + { + $output = $this->renderer->render([ + [ + 'version' => '1.0.0', + 'tag' => 'v1.0.0', + 'date' => '2026-04-01', + 'entries' => [ + 'Added' => ['Feature A'], + ], + ], + [ + 'version' => '1.1.0', + 'tag' => 'v1.1.0', + 'date' => '2026-04-02', + 'entries' => [ + 'Changed' => ['Feature B'], + ], + ], + ], 'git@github.com:php-fast-forward/dev-tools.git'); + + self::assertStringContainsString( + '[unreleased]: https://github.com/php-fast-forward/dev-tools/compare/v1.1.0...HEAD', + $output + ); + self::assertStringContainsString( + '[1.1.0]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.0...v1.1.0', + $output + ); + self::assertStringContainsString( + '[1.0.0]: https://github.com/php-fast-forward/dev-tools/releases/tag/v1.0.0', + $output + ); + } +} diff --git a/tests/Changelog/UnreleasedEntryCheckerTest.php b/tests/Changelog/UnreleasedEntryCheckerTest.php new file mode 100644 index 0000000..b9c6044 --- /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.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n### Added\n\n{$entry}\n\n## [1.0.0] - 2026-04-08\n\n### Added\n\n- Initial release.\n\n[unreleased]: https://github.com/php-fast-forward/dev-tools/compare/v1.0.0...HEAD\n[1.0.0]: https://github.com/php-fast-forward/dev-tools/releases/tag/v1.0.0\n"; + } +} diff --git a/tests/Command/AbstractCommandTestCase.php b/tests/Console/Command/AbstractCommandTestCase.php similarity index 99% rename from tests/Command/AbstractCommandTestCase.php rename to tests/Console/Command/AbstractCommandTestCase.php index 730a2bb..8b4bb9c 100644 --- a/tests/Command/AbstractCommandTestCase.php +++ b/tests/Console/Command/AbstractCommandTestCase.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use Composer\Composer; use Composer\Package\RootPackageInterface; diff --git a/tests/Console/Command/ChangelogCheckCommandTest.php b/tests/Console/Command/ChangelogCheckCommandTest.php new file mode 100644 index 0000000..18bfd60 --- /dev/null +++ b/tests/Console/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\Console\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->unreleasedEntryChecker->reveal(), $this->filesystem->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/Console/Command/ChangelogInitCommandTest.php b/tests/Console/Command/ChangelogInitCommandTest.php new file mode 100644 index 0000000..6aee751 --- /dev/null +++ b/tests/Console/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\Console\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->bootstrapper->reveal(), $this->filesystem->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/CodeStyleCommandTest.php b/tests/Console/Command/CodeStyleCommandTest.php similarity index 97% rename from tests/Command/CodeStyleCommandTest.php rename to tests/Console/Command/CodeStyleCommandTest.php index 68eb5a3..e1e36a7 100644 --- a/tests/Command/CodeStyleCommandTest.php +++ b/tests/Console/Command/CodeStyleCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\CodeStyleCommand; use PHPUnit\Framework\Attributes\CoversClass; @@ -159,7 +159,7 @@ public function executeWithoutLocalConfigWillRunCodeStyleProcessWithDevToolsConf { $this->willRunProcessWithCallback(function (Process $process): bool { $commandLine = $process->getCommandLine(); - $path = \dirname(__DIR__, 2) . '/ecs.php'; + $path = getcwd() . '/ecs.php'; return str_contains($commandLine, 'vendor/bin/ecs') && str_contains($commandLine, '--config=' . $path) @@ -181,7 +181,7 @@ public function executeWithFixOptionWillRunCodeStyleProcessWithoutDryRunOption() $this->willRunProcessWithCallback(function (Process $process): bool { $commandLine = $process->getCommandLine(); - $path = \dirname(__DIR__, 2) . '/ecs.php'; + $path = getcwd() . '/ecs.php'; return str_contains($commandLine, 'vendor/bin/ecs') && str_contains($commandLine, '--config=' . $path) diff --git a/tests/Command/CopyLicenseCommandTest.php b/tests/Console/Command/CopyLicenseCommandTest.php similarity index 98% rename from tests/Command/CopyLicenseCommandTest.php rename to tests/Console/Command/CopyLicenseCommandTest.php index d742e81..691ae9a 100644 --- a/tests/Command/CopyLicenseCommandTest.php +++ b/tests/Console/Command/CopyLicenseCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\CopyLicenseCommand; use FastForward\DevTools\License\Generator; diff --git a/tests/Command/DependenciesCommandTest.php b/tests/Console/Command/DependenciesCommandTest.php similarity index 99% rename from tests/Command/DependenciesCommandTest.php rename to tests/Console/Command/DependenciesCommandTest.php index 05b887f..b88b8a3 100644 --- a/tests/Command/DependenciesCommandTest.php +++ b/tests/Console/Command/DependenciesCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\DependenciesCommand; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/Command/DocsCommandTest.php b/tests/Console/Command/DocsCommandTest.php similarity index 98% rename from tests/Command/DocsCommandTest.php rename to tests/Console/Command/DocsCommandTest.php index 3016678..7f7dc5c 100644 --- a/tests/Command/DocsCommandTest.php +++ b/tests/Console/Command/DocsCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\DocsCommand; use FastForward\DevTools\Composer\Json\ComposerJson; diff --git a/tests/Command/GitAttributesCommandTest.php b/tests/Console/Command/GitAttributesCommandTest.php similarity index 99% rename from tests/Command/GitAttributesCommandTest.php rename to tests/Console/Command/GitAttributesCommandTest.php index ccc4be6..2b7e519 100644 --- a/tests/Command/GitAttributesCommandTest.php +++ b/tests/Console/Command/GitAttributesCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Composer\Json\ComposerJson; use FastForward\DevTools\Console\Command\GitAttributesCommand; diff --git a/tests/Command/GitIgnoreCommandTest.php b/tests/Console/Command/GitIgnoreCommandTest.php similarity index 99% rename from tests/Command/GitIgnoreCommandTest.php rename to tests/Console/Command/GitIgnoreCommandTest.php index 7eb59aa..be15246 100644 --- a/tests/Command/GitIgnoreCommandTest.php +++ b/tests/Console/Command/GitIgnoreCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\GitIgnoreCommand; use FastForward\DevTools\GitIgnore\GitIgnore; diff --git a/tests/Command/PhpDocCommandTest.php b/tests/Console/Command/PhpDocCommandTest.php similarity index 98% rename from tests/Command/PhpDocCommandTest.php rename to tests/Console/Command/PhpDocCommandTest.php index 80c5a88..a758849 100644 --- a/tests/Command/PhpDocCommandTest.php +++ b/tests/Console/Command/PhpDocCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Composer\Json\ComposerJson; use FastForward\DevTools\Console\Command\PhpDocCommand; diff --git a/tests/Command/RefactorCommandTest.php b/tests/Console/Command/RefactorCommandTest.php similarity index 97% rename from tests/Command/RefactorCommandTest.php rename to tests/Console/Command/RefactorCommandTest.php index a47367f..ba0621b 100644 --- a/tests/Command/RefactorCommandTest.php +++ b/tests/Console/Command/RefactorCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\RefactorCommand; use PHPUnit\Framework\Attributes\CoversClass; @@ -106,7 +106,7 @@ public function executeWithoutLocalConfigWillRunRectorProcessWithDevToolsConfigF $this->willRunProcessWithCallback(function (Process $process): bool { $commandLine = $process->getCommandLine(); - $path = \dirname(__DIR__, 2) . '/' . RefactorCommand::CONFIG; + $path = getcwd() . '/' . RefactorCommand::CONFIG; return str_contains($commandLine, 'vendor/bin/rector') && str_contains($commandLine, 'process') diff --git a/tests/Command/ReportsCommandTest.php b/tests/Console/Command/ReportsCommandTest.php similarity index 97% rename from tests/Command/ReportsCommandTest.php rename to tests/Console/Command/ReportsCommandTest.php index b07e831..8fee364 100644 --- a/tests/Command/ReportsCommandTest.php +++ b/tests/Console/Command/ReportsCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\ReportsCommand; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/Command/SkillsCommandTest.php b/tests/Console/Command/SkillsCommandTest.php similarity index 98% rename from tests/Command/SkillsCommandTest.php rename to tests/Console/Command/SkillsCommandTest.php index 4a90313..2c127d2 100644 --- a/tests/Command/SkillsCommandTest.php +++ b/tests/Console/Command/SkillsCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use Composer\IO\IOInterface; use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; diff --git a/tests/Command/StandardsCommandTest.php b/tests/Console/Command/StandardsCommandTest.php similarity index 97% rename from tests/Command/StandardsCommandTest.php rename to tests/Console/Command/StandardsCommandTest.php index 894c476..b6fa500 100644 --- a/tests/Command/StandardsCommandTest.php +++ b/tests/Console/Command/StandardsCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\StandardsCommand; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/Command/SyncCommandTest.php b/tests/Console/Command/SyncCommandTest.php similarity index 88% rename from tests/Command/SyncCommandTest.php rename to tests/Console/Command/SyncCommandTest.php index ef0d03c..fd0053f 100644 --- a/tests/Command/SyncCommandTest.php +++ b/tests/Console/Command/SyncCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\GitIgnoreCommand; use FastForward\DevTools\Console\Command\SyncCommand; @@ -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, GitHub Actions reusable workflows, changelog assets, .editorconfig, and .gitattributes 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/Command/TestsCommandTest.php b/tests/Console/Command/TestsCommandTest.php similarity index 99% rename from tests/Command/TestsCommandTest.php rename to tests/Console/Command/TestsCommandTest.php index f95b1ae..4121b7a 100644 --- a/tests/Command/TestsCommandTest.php +++ b/tests/Console/Command/TestsCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\TestsCommand; use FastForward\DevTools\Composer\Json\ComposerJson; diff --git a/tests/Command/WikiCommandTest.php b/tests/Console/Command/WikiCommandTest.php similarity index 98% rename from tests/Command/WikiCommandTest.php rename to tests/Console/Command/WikiCommandTest.php index 2ce0166..41dc333 100644 --- a/tests/Command/WikiCommandTest.php +++ b/tests/Console/Command/WikiCommandTest.php @@ -16,7 +16,7 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\Tests\Command; +namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Composer\Json\ComposerJson; use FastForward\DevTools\Console\Command\WikiCommand;