diff --git a/.agents/skills/changelog-generator/SKILL.md b/.agents/skills/changelog-generator/SKILL.md new file mode 100644 index 0000000..4eb5a57 --- /dev/null +++ b/.agents/skills/changelog-generator/SKILL.md @@ -0,0 +1,153 @@ +--- +name: changelog-generator +description: Generate and maintain CHANGELOG.md following Keep a Changelog format with human-readable descriptions. Use when: (1) Creating initial changelog from git tags, (2) Updating changelog for new releases, (3) Generating unreleased section for pull requests. Rule: NEVER use commit messages as source of truth - analyze code diffs instead. +--- + +# Changelog Generator + +Generates and maintains CHANGELOG.md following the Keep a Changelog format with clear, specific, and self-sufficient descriptions. + +## Dependencies + +- `phly/keep-a-changelog` - Installed in project +- Git - For analyzing code changes +- GitHub CLI (`gh`) - For reading PR context +- Filesystem - For reading/writing CHANGELOG.md + +## PR Context Integration + +When generating changelog for changes that belong to a PR: + +1. Detect PR reference: Check git branch name or recent PR comments +2. Fetch PR description: Use `gh pr view --json body` +3. Extract context: Read the PR Summary and Changes sections +4. Enhance descriptions: Use PR context to write more accurate changelog entries + +Example workflow: +```bash +# Detect current PR +BRANCH=$(git branch --show-current) +PR_NUM=$(echo "$BRANCH" | grep -oE '[0-9]+' | head -1) + +# Fetch PR context if exists +if [ -n "$PR_NUM" ]; then + PR_BODY=$(gh pr view "$PR_NUM" --json body --jq '.body') + # Use PR body as an extra context to define the changelog descriptions +fi +``` + +This ensures changelog descriptions align with the PR intent and provide better context. + +## Key Commands + +```bash +vendor/bin/changelog # Main CLI +vendor/bin/changelog add:entry # Add entry to version +vendor/bin/changelog release # Create release +``` + +## Execution Pipeline (Deterministic) + +### Stage 1: Initial State + +1. Check if CHANGELOG.md exists and has content: + ```bash + ls -la CHANGELOG.md 2>/dev/null || echo "NO_FILE" + ``` + +### Stage 2: Version Discovery + +1. List all tags sorted semantically: + ```bash + git tag --sort=-version:refname + ``` + +2. Identify: + - Last documented version in CHANGELOG + - Tags not yet documented + +### Stage 3: Historical Content Generation + +**Case A: No CHANGELOG or Empty** + +For each tag (ascending order): +1. Calculate diff between current tag and previous tag (or first commit for initial version) +2. Analyze code diff to infer changes (NOT commit messages) +3. Group changes by type (Added, Changed, Fixed, Removed, Deprecated, Security) +4. Insert version section + +**B: Existing CHANGELOG** + +1. Identify last documented version +2. For each subsequent tag: + - Generate diff between versions + - Insert new section in changelog + +### Stage 4: Unreleased Section + +1. Calculate diff between last documented tag and HEAD +2. Generate [Unreleased] section with current changes + +## Change Classification (Inferred from Diff) + +Analyze actual code changes, NOT commit messages: + +| Pattern | Category | +|---------|----------| +| New files, new classes, new methods | Added | +| Behavior changes, refactors, signature changes | Changed | +| Bug fixes, validation fixes | Fixed | +| Deleted classes, removed methods | Removed | +| @deprecated markers | Deprecated | +| Security patches | Security | + +## Quality Rules + +- **SHORT**: One line per change +- **SPECIFIC**: Include class/method names +- **SELF-SUFFICIENT**: Understand without reading code +- **FUNCTIONAL**: Describe impact, not implementation +- **PR-AWARE**: Use PR description to enhance accuracy when available + +Good: "Added `Bootstrapper::bootstrap()` to create CHANGELOG.md when missing" +Bad: "Add bootstrap command" + +## PR Context Usage + +When a PR number is available: + +1. Read PR description for implementation intent +2. Extract key capabilities mentioned in Summary +3. Use specific feature names from the PR to write accurate descriptions +4. Reference PR in changelog: "Added changelog automation (#40)" + +## Integration with keep-a-changelog + +Use CLI commands when possible: + +```bash +# Add unreleased entry +vendor/bin/changelog add:entry --unreleased --type=added "Description" + +# Add release entry +vendor/bin/changelog add:entry 1.0.0 --type=added "Description" + +# Create release +vendor/bin/changelog release 1.0.0 --date="2026-04-11" +``` + +Edit CHANGELOG.md directly if CLI insufficient. + +## Verification + +Valid changelog MUST have: +- All sections: Added, Changed, Deprecated, Removed, Fixed, Security +- No "Nothing." placeholders (unless truly empty) +- Reverse chronological order (newest first) +- [Unreleased] at top when applicable + +## Reference Files + +- [references/keep-a-changelog-format.md](references/keep-a-changelog-format.md) - Format spec +- [references/change-categories.md](references/change-categories.md) - Classification guide +- [references/description-patterns.md](references/description-patterns.md) - Human-readable patterns diff --git a/.agents/skills/changelog-generator/agents/openai.yaml b/.agents/skills/changelog-generator/agents/openai.yaml new file mode 100644 index 0000000..794c7cf --- /dev/null +++ b/.agents/skills/changelog-generator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fast Forward Changelog Generator" + short_description: "Generate and maintain Keep a Changelog entries" + default_prompt: "Use $changelog-generator to create CHANGELOG.md from git tags" diff --git a/.agents/skills/changelog-generator/references/change-categories.md b/.agents/skills/changelog-generator/references/change-categories.md new file mode 100644 index 0000000..73336d1 --- /dev/null +++ b/.agents/skills/changelog-generator/references/change-categories.md @@ -0,0 +1,74 @@ +# Change Categories + +## Classification by Diff Analysis + +Infer category from code patterns, NOT from commit messages. + +## Added + +**Patterns:** +- New PHP class files +- New methods in existing classes +- New configuration options +- New CLI commands +- New public APIs + +**Examples:** +- `+class Bootstrapper` → "Added `Bootstrapper` class for changelog bootstrapping" +- `+public function render()` → "Added `MarkdownRenderer::render()` method" +- `+->addOption()` → "Added `--output` option to command" + +## Changed + +**Patterns:** +- Modified method signatures +- Changed default values +- Behavior modifications +- Refactors that affect the public API + +**Examples:** +- `function foo($bar)` → `function foo($bar, $baz = null)` → "Changed `foo()` to accept optional `$baz` parameter" +- `return void` → `return string` → "Changed `render()` to return string instead of void" + +## Fixed + +**Patterns:** +- Bug fixes +- Validation improvements +- Edge case handling +- Error handling corrections + +**Examples:** +- Empty input validation, null checks → "Fixed handling of null input in `parse()`" +- Regex fixes → "Fixed validation of version numbers" + +## Removed + +**Patterns:** +- Deleted classes +- Deleted methods +- Deleted configuration options + +**Examples:** +- `-class LegacyParser` → "Removed deprecated `LegacyParser` class" +- `-function oldMethod()` → "Removed deprecated `oldMethod()` method" + +## Deprecated + +**Patterns:** +- @deprecated annotations +- Deprecation notices in code + +**Examples:** +- `@deprecated` → "Deprecated `LegacyParser`, use `MarkdownParser` instead" + +## Security + +**Patterns:** +- Security patches +- Vulnerability fixes +- Input sanitization + +**Examples:** +- XSS fixes → "Fixed XSS vulnerability in user input" +- CSRF protection → "Added CSRF protection to form handling" \ No newline at end of file diff --git a/.agents/skills/changelog-generator/references/description-patterns.md b/.agents/skills/changelog-generator/references/description-patterns.md new file mode 100644 index 0000000..339af1b --- /dev/null +++ b/.agents/skills/changelog-generator/references/description-patterns.md @@ -0,0 +1,64 @@ +# Description Patterns + +## How to Write Human-Readable Descriptions + +Rule: Describe the IMPACT, not the IMPLEMENTATION. + +## Quality Criteria + +| Criterion | Good | Bad | +|-----------|------|-----| +| Specific | "Added `ChangelogCheckCommand` to verifies that the changelog contains pending unreleased notes." | "Added new feature" | +| Short | One line | Paragraph | +| Self-sufficient | "Creates CHANGELOG.md with all current version released" | "Bootstrap support" | +| Actionable | "Added `--filter` option on TestsCommand to be able to filter test pattern classes" | "Improved CLI" | + +## Transformation Examples + +### Bad → Good + +``` +Bad: "feat: add bootstrap" +Good: "Added `Bootstrapper` class to create CHANGELOG.md when missing" + +Bad: "refactor: extract to new class" +Good: "Extracted `CommitClassifier` for improved separation of concerns" + +Bad: "fix: validate unreleased notes" +Good: "Fixed validation of unreleased changelog entries" + +Bad: "chore: update dependencies" +Good: N/A - Skip infrastructure-only changes +``` + +## Class Names Pattern + +Always include class/method names: + +```markdown +- Added `Bootstrapper` to bootstrap changelog assets +- Added `MarkdownRenderer::render()` for generating output +- Changed `Config::load()` to accept optional path parameter +- Fixed `Parser::parse()` handling of empty input +``` + +## API Changes Pattern + +```markdown +- Added `CommandInterface::execute()` method +- Changed `Parser::parse($input)` to accept optional `$options` array +- Removed deprecated `LegacyCommand` +- Deprecated `Parser::process()`, use `Renderer::render()` instead +``` + +## Reference Patterns (PR) + +When changes came from a PR: + +```markdown +- Added changelog automation (#28) +- Changed workflow to use PHP 8.3 (#31) +- Fixed validation bug (#42) +``` + +This helps users find more context in PR history. diff --git a/.agents/skills/changelog-generator/references/keep-a-changelog-format.md b/.agents/skills/changelog-generator/references/keep-a-changelog-format.md new file mode 100644 index 0000000..4f3026d --- /dev/null +++ b/.agents/skills/changelog-generator/references/keep-a-changelog-format.md @@ -0,0 +1,286 @@ +# Keep a Changelog Format + +## Structure + +```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 + +- v1.1 Brazilian Portuguese translation. +- v1.1 German Translation +- v1.1 Spanish translation. +- v1.1 Italian translation. +- v1.1 Polish translation. +- v1.1 Ukrainian translation. + +### Changed + +- Use frontmatter title & description in each language version template +- Replace broken OpenGraph image with an appropriately-sized Keep a Changelog + image that will render properly (although in English for all languages) +- Fix OpenGraph title & description for all languages so the title and +description when links are shared are language-appropriate + +### Removed + +- Trademark sign previously shown after the project description in version +0.3.0 + +## [1.1.1] - 2023-03-05 + +### Added + +- Arabic translation (#444). +- v1.1 French translation. +- v1.1 Dutch translation (#371). +- v1.1 Russian translation (#410). +- v1.1 Japanese translation (#363). +- v1.1 Norwegian Bokmål translation (#383). +- v1.1 "Inconsistent Changes" Turkish translation (#347). +- Default to most recent versions available for each languages. +- Display count of available translations (26 to date!). +- Centralize all links into `/data/links.json` so they can be updated easily. + +### Fixed + +- Improve French translation (#377). +- Improve id-ID translation (#416). +- Improve Persian translation (#457). +- Improve Russian translation (#408). +- Improve Swedish title (#419). +- Improve zh-CN translation (#359). +- Improve French translation (#357). +- Improve zh-TW translation (#360, #355). +- Improve Spanish (es-ES) transltion (#362). +- Foldout menu in Dutch translation (#371). +- Missing periods at the end of each change (#451). +- Fix missing logo in 1.1 pages. +- Display notice when translation isn't for most recent version. +- Various broken links, page versions, and indentations. + +### Changed + +- Upgrade dependencies: Ruby 3.2.1, Middleman, etc. + +### Removed + +- Unused normalize.css file. +- Identical links assigned in each translation file. +- Duplicate index file for the english version. + +## [1.1.0] - 2019-02-15 + +### Added + +- Danish translation (#297). +- Georgian translation from (#337). +- Changelog inconsistency section in Bad Practices. + +### Fixed + +- Italian translation (#332). +- Indonesian translation (#336). + +## [1.0.0] - 2017-06-20 + +### Added + +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. +- Links to latest released version in previous versions. +- "Why keep a changelog?" section. +- "Who needs a changelog?" section. +- "How do I make a changelog?" section. +- "Frequently Asked Questions" section. +- New "Guiding Principles" sub-section to "How do I make a changelog?". +- Simplified and Traditional Chinese translations from [@tianshuo](https://github.com/tianshuo). +- German translation from [@mpbzh](https://github.com/mpbzh) & [@Art4](https://github.com/Art4). +- Italian translation from [@azkidenz](https://github.com/azkidenz). +- Swedish translation from [@magol](https://github.com/magol). +- Turkish translation from [@emreerkan](https://github.com/emreerkan). +- French translation from [@zapashcanon](https://github.com/zapashcanon). +- Brazilian Portuguese translation from [@Webysther](https://github.com/Webysther). +- Polish translation from [@amielucha](https://github.com/amielucha) & [@m-aciek](https://github.com/m-aciek). +- Russian translation from [@aishek](https://github.com/aishek). +- Czech translation from [@h4vry](https://github.com/h4vry). +- Slovak translation from [@jkostolansky](https://github.com/jkostolansky). +- Korean translation from [@pierceh89](https://github.com/pierceh89). +- Croatian translation from [@porx](https://github.com/porx). +- Persian translation from [@Hameds](https://github.com/Hameds). +- Ukrainian translation from [@osadchyi-s](https://github.com/osadchyi-s). + +### Changed + +- Start using "changelog" over "change log" since it's the common usage. +- Start versioning based on the current English version at 0.3.0 to help + translation authors keep things up-to-date. +- Rewrite "What makes unicorns cry?" section. +- Rewrite "Ignoring Deprecations" sub-section to clarify the ideal + scenario. +- Improve "Commit log diffs" sub-section to further argument against + them. +- Merge "Why can’t people just use a git log diff?" with "Commit log + diffs". +- Fix typos in Simplified Chinese and Traditional Chinese translations. +- Fix typos in Brazilian Portuguese translation. +- Fix typos in Turkish translation. +- Fix typos in Czech translation. +- Fix typos in Swedish translation. +- Improve phrasing in French translation. +- Fix phrasing and spelling in German translation. + +### Removed + +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 + +### Added + +- RU translation from [@aishek](https://github.com/aishek). +- pt-BR translation from [@tallesl](https://github.com/tallesl). +- es-ES translation from [@ZeliosAriex](https://github.com/ZeliosAriex). + +## [0.2.0] - 2015-10-06 + +### Changed + +- Remove exclusionary mentions of "open source" since this project can + benefit both "open" and "closed" source projects equally. + +## [0.1.0] - 2015-10-06 + +### Added + +- Answer "Should you ever rewrite a change log?". + +### Changed + +- Improve argument against commit logs. +- Start following [SemVer](https://semver.org) properly. + +## [0.0.8] - 2015-02-17 + +### Changed + +- Update year to match in every README example. +- Reluctantly stop making fun of Brits only, since most of the world + writes dates in a strange way. + +### Fixed + +- Fix typos in recent README changes. +- Update outdated unreleased diff link. + +## [0.0.7] - 2015-02-16 + +### Added + +- Link, and make it obvious that date format is ISO 8601. + +### Changed + +- Clarified the section on "Is there a standard change log format?". + +### Fixed + +- Fix Markdown links to tag comparison URL with footnote-style links. + +## [0.0.6] - 2014-12-12 + +### Added + +- README section on "yanked" releases. + +## [0.0.5] - 2014-08-09 + +### Added + +- Markdown links to version tags on release headings. +- Unreleased section to gather unreleased changes and encourage note + keeping prior to releases. + +## [0.0.4] - 2014-08-09 + +### Added + +- Better explanation of the difference between the file ("CHANGELOG") + and its function "the change log". + +### Changed + +- Refer to a "change log" instead of a "CHANGELOG" throughout the site + to differentiate between the file and the purpose of the file — the + logging of changes. + +### Removed + +- Remove empty sections from CHANGELOG, they occupy too much space and + create too much noise in the file. People will have to assume that the + missing sections were intentionally left out because they contained no + notable changes. + +## [0.0.3] - 2014-08-09 + +### Added + +- "Why should I care?" section mentioning The Changelog podcast. + +## [0.0.2] - 2014-07-10 + +### Added + +- Explanation of the recommended reverse chronological release ordering. + +## [0.0.1] - 2014-05-31 + +### Added + +- This CHANGELOG file to hopefully serve as an evolving example of a + standardized open source project CHANGELOG. +- CNAME file to enable GitHub Pages custom domain. +- README now contains answers to common questions about CHANGELOGs. +- Good examples and basic guidelines, including proper date formatting. +- Counter-examples: "What makes unicorns cry?". + +[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.3.0...v1.0.0 +[0.3.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.8...v0.1.0 +[0.0.8]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.7...v0.0.8 +[0.0.7]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.6...v0.0.7 +[0.0.6]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.5...v0.0.6 +[0.0.5]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.4...v0.0.5 +[0.0.4]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.3...v0.0.4 +[0.0.3]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.2...v0.0.3 +[0.0.2]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.0.1...v0.0.2 +[0.0.1]: https://github.com/olivierlacan/keep-a-changelog/releases/tag/v0.0.1 +``` + +## Entry Format Rules + +1. **One change per line** +2. **Use past tense**: Added, Changed, Fixed, Removed, Deprecated +3. **Include specifics**: Class names, method names, file names +4. **Be actionable**: Users should understand what changed without reading code + +## Section Order + +Always in this order: +1. Added +2. Changed +3. Deprecated +4. Removed +5. Fixed +6. Security diff --git a/.agents/skills/github-pull-request/SKILL.md b/.agents/skills/github-pull-request/SKILL.md index 4f4f2c9..c058193 100644 --- a/.agents/skills/github-pull-request/SKILL.md +++ b/.agents/skills/github-pull-request/SKILL.md @@ -21,10 +21,21 @@ Use this skill to take a Fast Forward issue from "ready to implement" to an open - Branch from `main` or the repository integration branch, never from another feature branch. - Prefer local `git` for checkout, commit, and push. - Prefer connector-backed GitHub data for issue and PR context when available. -- Use `phpunit-tests`, `package-readme`, and `sphinx-docs` when the change clearly affects tests or documentation. +- Use `phpunit-tests`, `package-readme`, `sphinx-docs`, and `changelog-generator` when the change clearly affects tests or documentation. - Never manually close an issue; rely on `Closes #123` style text in the PR body. - Do not block waiting for merge. Open or update the PR, then report status and the next action. +## Changelog Updates + +When implementing changes that affect functionality, use `changelog-generator` to update CHANGELOG.md: + +1. Run `changelog-generator` to analyze code changes since last release +2. Add clear, specific descriptions following the skill's quality rules +3. Include PR reference when applicable: "Added changelog automation (#40)" +4. Update the [Unreleased] section for PR-specific changes + +This ensures every PR has proper changelog documentation before merge. + ## Reference Guide | Need | Reference | diff --git a/.agents/skills/github-pull-request/references/pr-drafting.md b/.agents/skills/github-pull-request/references/pr-drafting.md index 53d82b1..96c10a3 100644 --- a/.agents/skills/github-pull-request/references/pr-drafting.md +++ b/.agents/skills/github-pull-request/references/pr-drafting.md @@ -31,6 +31,9 @@ If no PR template exists, use the fallback structure below. - [Concrete change] - [Concrete change] +## Changelog +- Added `ClassName` for feature description (#PR) + ## Testing - [Command and result] - [Command and result] @@ -38,6 +41,15 @@ If no PR template exists, use the fallback structure below. Closes #123 ``` +## Changelog Entry Rule + +Every PR that adds functionality MUST include a changelog entry. Use the `changelog-generator` skill to generate proper entries: + +- Short: one line per change +- Specific: include changes human description +- Self-sufficient: understandable without reading code +- Reference the PR number: "Added `dev-tool:sync` command to sincronize repository files (#363)." + ## Title Guidance - Follow repository title rules when they exist. diff --git a/.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..cb8d00a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Sync Release Notes from CHANGELOG + +on: + workflow_call: + inputs: + tag-name: + type: string + required: false + release: + types: [published] + +permissions: + contents: write + +jobs: + release-notes: + name: Promote CHANGELOG and Sync Release Notes + runs-on: ubuntu-latest + + steps: + - name: Checkout main branch + uses: actions/checkout@v6 + with: + ref: main + token: ${{ github.token }} + fetch-depth: 0 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + uses: php-actions/composer@v6 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + COMPOSER_CACHE_DIR: /tmp/composer-cache + with: + php_version: '8.3' + command: 'install' + args: '--prefer-dist --no-progress --no-interaction --no-scripts' + + - name: Resolve release metadata + id: release + run: | + TAG="${INPUT_TAG_NAME:-${EVENT_TAG_NAME:-${GITHUB_REF_NAME}}}" + VERSION="${TAG#v}" + DATE_VALUE="${EVENT_PUBLISHED_AT%%T*}" + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "date=${DATE_VALUE:-$(date +%F)}" >> "$GITHUB_OUTPUT" + env: + INPUT_TAG_NAME: ${{ inputs.tag-name }} + EVENT_TAG_NAME: ${{ github.event.release.tag_name }} + EVENT_PUBLISHED_AT: ${{ github.event.release.published_at }} + + - name: Promote unreleased changelog entry + run: | + VERSION="${{ steps.release.outputs.version }}" + DATE_VALUE="${{ steps.release.outputs.date }}" + + if grep -q "^## ${VERSION} - " CHANGELOG.md; then + echo "Release ${VERSION} already exists in CHANGELOG.md." + elif grep -q "^## Unreleased - " CHANGELOG.md; then + vendor/bin/keep-a-changelog unreleased:promote "${VERSION}" --date="${DATE_VALUE}" --no-interaction + else + echo "No Unreleased section found; skipping promotion." + fi + + - name: Ensure the next Unreleased section exists + run: | + if ! grep -q "^## Unreleased - " CHANGELOG.md; then + vendor/bin/keep-a-changelog unreleased:create --no-interaction + fi + + - name: Commit CHANGELOG updates + uses: EndBug/add-and-commit@v10 + with: + add: CHANGELOG.md + message: "Sync changelog for ${{ steps.release.outputs.tag }}" + default_author: github_actions + pull: "--rebase --autostash" + push: true + + - name: Export release notes from CHANGELOG + run: vendor/bin/keep-a-changelog version:show "${{ steps.release.outputs.version }}" > release-notes.md + + - name: Update GitHub release notes + run: gh release edit "${TAG}" --notes-file release-notes.md + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} diff --git a/.github/workflows/require-changelog.yml b/.github/workflows/require-changelog.yml new file mode 100644 index 0000000..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..225aabf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,129 @@ +# Changelog + +All notable changes to this project will be documented in this file, in reverse chronological order by release. + +## [Unreleased] - TBD + +### Added + +- Add changelog bootstrap, validation commands, and reusable workflows + +### Changed + +- Sync changelog scripts and release note automation into consumer repositories + +## [1.5.0] - 2026-04-11 + +### Added + +- Added `Bootstrapper` class to bootstrap `.keep-a-changelog.ini` and `CHANGELOG.md` +- Added `BootstrapResult` value object for bootstrap outcome reporting +- Added `CommitClassifier` to classify commits into keep-a-changelog sections +- Added `GitProcessRunner` for git command execution in changelog workflows +- Added `GitReleaseCollector` to discover release tags and commits +- Added `HistoryGenerator` to render markdown from git history +- Added `KeepAChangelogConfigRenderer` for config file generation +- Added `MarkdownRenderer` for keep-a-changelog format output +- Added `UnreleasedEntryChecker` to compare unreleased entries against git baseline +- Added `ChangelogCheckCommand` (`changelog:check`) for changelog validation +- Added `ChangelogInitCommand` (`changelog:init`) for changelog bootstrapping +- Added reusable GitHub Actions workflows in `resources/github-actions/` +- Added documentation in `docs/usage/changelog-management.rst` + +### Changed + +- Improved `SyncCommand` to synchronize changelog automation assets +- Updated GitHub Actions to use PHP 8.3 + +## [1.4.0] - 2026-04-11 + +### Added + +- Added `CoverageSummary` for programmatic PHPUnit coverage data access +- Added `CoverageSummaryLoader` for loading coverage summaries + +### Changed + +- Replaced coverage-check dependency + +### Fixed + +- Updated Symfony components to support version 8.0 + +## [1.3.0] - 2026-04-11 + +### Added + +- Added `context7.json` to export-ignore list +- Added `GitAttributes` management with Reader, Merger, and Writer implementations +- Added `GitAttributesCommand` to manage export-ignore rules +- Added comprehensive tests for GitAttributes and License components +- Added License file generation support + +### Changed + +- Isolated `GitAttributesCommand` as standalone command + +## [1.2.2] - 2026-04-10 + +### Changed + +- Updated .gitattributes + +### Fixed + +- Enhanced documentation for license generation classes + +## [1.2.1] - 2026-04-10 + +### Fixed + +- Enhanced documentation for license generation + +## [1.2.0] - 2026-04-10 + +### Added + +- Added `CopyLicenseCommand` for LICENSE file generation +- Added `DependenciesCommand` for Composer dependency analysis +- Added `SkillsCommand` to synchronize packaged agent skills +- Added PHPDoc and PHPUnit test skeleton generation skills + +### Changed + +- Bundled dependency analysers with dev-tools + +### Fixed + +- Updated dependency analysis command + +## [1.1.0] - 2026-04-09 + +### Added + +- Added GrumPHP integration for Git hooks +- Added Rector automated refactoring +- Added ECS code style enforcement +- Added API documentation generation + +### Changed + +- Updated Composer scripts prefix to `dev-tools` + +## [1.0.0] - 2026-04-08 + +### Added + +- Added Composer plugin (`FastForward\DevTools\Composer\Plugin`) for unified dev-tools commands +- Added `CodeStyleCommand` (`dev-tools:code-style`) for ECS and code style fixes +- Added `DocsCommand` (`dev-tools:docs`) for Sphinx documentation generation +- Added `PhpDocCommand` (`dev-tools:phpdoc`) for PHPDoc validation and fixes +- Added `RefactorCommand` (`dev-tools:refactor`) for Rector refactoring +- Added `ReportsCommand` (`dev-tools:reports`) for API documentation generation +- Added `StandardsCommand` (`dev-tools:standards`) for combined quality checks +- Added `SyncCommand` (`dev-tools:sync`) to synchronize GitHub Actions and .editorconfig +- Added `TestsCommand` (`dev-tools:tests`) for PHPUnit test execution +- Added `WikiCommand` (`dev-tools:wiki`) for GitHub wiki generation +- Added `DevToolsExtension` for PHPUnit integration with JoliNotif notifications +- Added custom Rector rules for PHPDoc generation +- Added GitHub Actions workflows for CI/CD diff --git a/README.md b/README.md index ce43b3b..bfb9d43 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ across Fast Forward libraries. single Composer-facing command vocabulary - Adds dependency analysis for missing and unused Composer packages through a single report entrypoint +- Bootstraps Keep a Changelog automation, reusable release workflows, and + GitHub release note synchronization - Ships shared workflow stubs, `.editorconfig`, Dependabot configuration, and other onboarding defaults for consumer repositories - Synchronizes packaged agent skills into consumer `.agents/skills` @@ -53,6 +55,10 @@ composer dev-tools tests composer dependencies vendor/bin/dev-tools dependencies +# Bootstrap and validate Keep a Changelog assets +composer dev-tools changelog:init +composer dev-tools changelog:check + # Check and fix code style using ECS and Composer Normalize composer dev-tools code-style @@ -84,8 +90,8 @@ composer dev-tools gitattributes composer dev-tools license # Installs and synchronizes dev-tools scripts, GitHub Actions workflows, -# .editorconfig, .gitignore rules, packaged skills, and the repository wiki -# submodule in .github/wiki +# changelog automation assets, .editorconfig, .gitignore rules, packaged +# skills, and the repository wiki submodule in .github/wiki composer dev-tools:sync ``` @@ -106,10 +112,12 @@ automation assets. | `composer dev-tools` | Runs the full `standards` pipeline. | | `composer dev-tools tests` | Runs PHPUnit with local-or-packaged configuration. | | `composer dev-tools dependencies` | Reports missing and unused Composer dependencies. | +| `composer dev-tools changelog:init` | Creates local changelog automation assets. | +| `composer dev-tools changelog:check` | Verifies the `Unreleased` changelog section contains new notes. | | `composer dev-tools docs` | Builds the HTML documentation site from PSR-4 code and `docs/`. | | `composer dev-tools skills` | Creates or repairs packaged skill links in `.agents/skills`. | | `composer dev-tools gitattributes` | Manages export-ignore rules in .gitattributes. | -| `composer dev-tools:sync` | Updates scripts, workflow stubs, `.editorconfig`, `.gitignore`, `.gitattributes`, wiki setup, and packaged skills. | +| `composer dev-tools:sync` | Updates scripts, workflow stubs, changelog assets, `.editorconfig`, `.gitignore`, `.gitattributes`, wiki setup, and packaged skills. | ## 🔌 Integration @@ -117,13 +125,16 @@ DevTools integrates with consumer repositories in two ways. The Composer plugin exposes the command set automatically after installation, and the local binary keeps the same command vocabulary when you prefer running tools directly from `vendor/bin/dev-tools`. The consumer sync flow also refreshes `.agents/skills` -so agents can discover the packaged skills shipped with this repository. +so agents can discover the packaged skills shipped with this repository. It +also bootstraps `.keep-a-changelog.ini`, `CHANGELOG.md`, and reusable release +workflows for changelog enforcement. ## 🤝 Contributing Run `composer dev-tools` before opening a pull request. If you change public commands or consumer onboarding behavior, update `README.md` and `docs/` -together so downstream libraries keep accurate guidance. +together so downstream libraries keep accurate guidance, and add a note to the +`Unreleased` section of `CHANGELOG.md`. ## 📄 License diff --git a/composer.json b/composer.json index b18f188..a84c27c 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "icanhazstring/composer-unused": "^0.9.6", "jolicode/jolinotif": "^3.3", "nikic/php-parser": "^5.7", + "phly/keep-a-changelog": "^2.13", "phpdocumentor/shim": "^3.9", "phpro/grumphp": "^2.19", "phpspec/prophecy": "^1.26", @@ -70,6 +71,7 @@ "config": { "allow-plugins": { "ergebnis/composer-normalize": true, + "php-http/discovery": true, "phpdocumentor/shim": true, "phpro/grumphp": true, "pyrech/composer-changelogs": true diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 07628a7..cb4c25c 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -33,6 +33,12 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch. * - ``FastForward\DevTools\Command\DependenciesCommand`` - ``dependencies`` - Reports missing and unused Composer dependencies. + * - ``FastForward\DevTools\Command\ChangelogInitCommand`` + - ``changelog:init`` + - Bootstraps ``CHANGELOG.md`` and keep-a-changelog configuration. + * - ``FastForward\DevTools\Command\ChangelogCheckCommand`` + - ``changelog:check`` + - Verifies that the ``Unreleased`` section contains meaningful notes. * - ``FastForward\DevTools\Command\DocsCommand`` - ``docs`` - Builds the HTML documentation site. @@ -47,11 +53,11 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch. - Synchronizes packaged agent skills into ``.agents/skills``. * - ``FastForward\DevTools\Command\SyncCommand`` - ``dev-tools:sync`` - - Synchronizes consumer-facing scripts, automation assets, and packaged - skills. - * - ``FastForward\DevTools\Command\GitIgnoreCommand`` - - ``gitignore`` - - Merges and synchronizes .gitignore files. - * - ``FastForward\DevTools\Command\CopyLicenseCommand`` - - ``license`` - - Generates a LICENSE file from composer.json license information. + - Synchronizes consumer-facing scripts, automation assets, changelog + workflows, and packaged skills. + * - ``FastForward\DevTools\Command\GitIgnoreCommand`` + - ``gitignore`` + - Merges and synchronizes .gitignore files. + * - ``FastForward\DevTools\Command\CopyLicenseCommand`` + - ``license`` + - Generates a LICENSE file from composer.json license information. diff --git a/docs/api/phpunit-support.rst b/docs/api/phpunit-support.rst index 1288310..1a2f9cc 100644 --- a/docs/api/phpunit-support.rst +++ b/docs/api/phpunit-support.rst @@ -19,18 +19,18 @@ The packaged test configuration includes a small integration layer under * - ``FastForward\DevTools\PhpUnit\Event\TestSuite\ByPassfinalsStartedSubscriber`` - Enables ``DG\BypassFinals`` - Allows tests to work with final constructs. - * - ``FastForward\DevTools\PhpUnit\Event\TestSuite\JoliNotifExecutionFinishedSubscriber`` - - Sends desktop notifications - - Summarizes pass, failure, error, runtime, and memory data. - * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface`` - - Loads PHPUnit coverage reports - - Contract for loading serialized PHP coverage data. - * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader`` - - Loads PHPUnit coverage reports - - Implementation that reads ``coverage-php`` output. - * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummary`` - - Represents line coverage metrics - - Provides executed lines, total executable lines, and percentage calculations. + * - ``FastForward\DevTools\PhpUnit\Event\TestSuite\JoliNotifExecutionFinishedSubscriber`` + - Sends desktop notifications + - Summarizes pass, failure, error, runtime, and memory data. + * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface`` + - Loads PHPUnit coverage reports + - Contract for loading serialized PHP coverage data. + * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader`` + - Loads PHPUnit coverage reports + - Implementation that reads ``coverage-php`` output. + * - ``FastForward\DevTools\PhpUnit\Coverage\CoverageSummary`` + - Represents line coverage metrics + - Provides executed lines, total executable lines, and percentage calculations. Coverage Report Loading ----------------------- diff --git a/docs/links/dependencies.rst b/docs/links/dependencies.rst index 69d9cdd..3e15d46 100644 --- a/docs/links/dependencies.rst +++ b/docs/links/dependencies.rst @@ -55,6 +55,9 @@ Documentation and Reporting - Provides the default HTML theme used by ``docs``. * - ``esi/phpunit-coverage-check`` - Enforces the minimum coverage threshold in the reusable test workflow. + * - ``phly/keep-a-changelog`` + - Powers changelog bootstrapping, unreleased promotion, and release note + synchronization. Testing and Local Developer Experience -------------------------------------- diff --git a/docs/usage/changelog-management.rst b/docs/usage/changelog-management.rst new file mode 100644 index 0000000..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..6f7f9bb --- /dev/null +++ b/src/Changelog/Bootstrapper.php @@ -0,0 +1,112 @@ + + * @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 +{ + /** + * 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 - ')) { + $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 + { + $heading = "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n"; + $unreleasedSection = "## Unreleased - TBD\n\n### Added\n\n- Nothing.\n\n### Changed\n\n- Nothing.\n\n### Deprecated\n\n- Nothing.\n\n### Removed\n\n- Nothing.\n\n### Fixed\n\n- Nothing.\n\n### Security\n\n- Nothing.\n\n"; + + $updatedContents = str_replace($heading, $heading . $unreleasedSection, $contents); + + if ($updatedContents !== $contents) { + return $updatedContents; + } + + $firstSecondaryHeadingOffset = strpos($contents, "\n## "); + + if (false === $firstSecondaryHeadingOffset) { + return rtrim($contents) . "\n\n" . $unreleasedSection; + } + + return substr($contents, 0, $firstSecondaryHeadingOffset + 1) + . $unreleasedSection + . substr($contents, $firstSecondaryHeadingOffset + 1); + } +} diff --git a/src/Changelog/BootstrapperInterface.php b/src/Changelog/BootstrapperInterface.php new file mode 100644 index 0000000..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..42101b0 --- /dev/null +++ b/src/Changelog/HistoryGenerator.php @@ -0,0 +1,72 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use function array_values; + +/** + * Converts release metadata and commit subjects into a rendered changelog file. + */ +final readonly class HistoryGenerator implements HistoryGeneratorInterface +{ + /** + * Initializes the `HistoryGenerator` with optional dependencies. + * + * @param GitReleaseCollectorInterface $gitReleaseCollector git release collector instance for collecting release metadata and commit subjects + * @param CommitClassifierInterface $commitClassifier commit classifier instance for classifying and normalizing commit subjects into changelog sections + * @param MarkdownRenderer $markdownRenderer markdown renderer instance for rendering the final changelog markdown from structured release and commit data + */ + public function __construct( + private GitReleaseCollectorInterface $gitReleaseCollector = new GitReleaseCollector(), + private CommitClassifierInterface $commitClassifier = new CommitClassifier(), + private MarkdownRenderer $markdownRenderer = new MarkdownRenderer(), + ) {} + + /** + * @param string $workingDirectory + * + * @return string + */ + public function generate(string $workingDirectory): string + { + $releases = []; + + foreach ($this->gitReleaseCollector->collect($workingDirectory) as $release) { + $entries = []; + + foreach ($release['commits'] as $subject) { + $section = $this->commitClassifier->classify($subject); + $entries[$section] ??= []; + $entries[$section][] = $this->commitClassifier->normalize($subject); + } + + foreach ($entries as $section => $sectionEntries) { + $entries[$section] = array_values(array_unique($sectionEntries)); + } + + $releases[] = [ + 'version' => $release['version'], + 'date' => $release['date'], + 'entries' => $entries, + ]; + } + + return $this->markdownRenderer->render($releases); + } +} 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..93da5f8 --- /dev/null +++ b/src/Changelog/MarkdownRenderer.php @@ -0,0 +1,109 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog; + +use function array_reverse; +use function implode; + +/** + * Renders Keep a Changelog markdown in a deterministic package-friendly format. + */ +final readonly class MarkdownRenderer +{ + /** + * @var list + */ + private const array SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']; + + /** + * Renders the changelog markdown content. + * + * @param list>}> $releases list of releases with their version, date, and entries + * + * @return string the generated changelog markdown content + */ + public function render(array $releases): string + { + $lines = [ + '# Changelog', + '', + 'All notable changes to this project will be documented in this file, in reverse chronological order by release.', + '', + ]; + + $lines = [...$lines, ...$this->renderSection('Unreleased', 'TBD', [])]; + + foreach (array_reverse($releases) as $release) { + $lines = [ + ...$lines, + ...$this->renderSection($release['version'], $release['date'], $release['entries']), + ]; + } + + return implode("\n", $lines) . "\n"; + } + + /** + * 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, '']; + $hasEntries = false; + + foreach (self::SECTION_ORDER as $section) { + $sectionEntries = $entries[$section] ?? []; + + if ('Unreleased' !== $version && [] === $sectionEntries) { + continue; + } + + $hasEntries = true; + $lines[] = '### ' . $section; + $lines[] = ''; + + if ([] === $sectionEntries) { + $lines[] = '- Nothing.'; + $lines[] = ''; + continue; + } + + foreach ($sectionEntries as $entry) { + $lines[] = '- ' . $entry; + } + + $lines[] = ''; + } + + if (! $hasEntries) { + $lines[] = '### Changed'; + $lines[] = ''; + $lines[] = '- Nothing.'; + $lines[] = ''; + } + + return $lines; + } +} diff --git a/src/Changelog/UnreleasedEntryChecker.php b/src/Changelog/UnreleasedEntryChecker.php new file mode 100644 index 0000000..4d459c5 --- /dev/null +++ b/src/Changelog/UnreleasedEntryChecker.php @@ -0,0 +1,119 @@ + + * @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\]?\s+-\s+.+?(?=^##\s|\z)/ms', $contents, $matches)) { + return []; + } + + $lines = preg_split('/\R/', trim($matches[0])); + + return array_values(array_filter(array_map(static function (string $line): ?string { + $line = trim($line); + + if (0 === preg_match('/^- (.+)$/', $line, $matches)) { + return null; + } + + $entry = trim($matches[1]); + + if ('Nothing.' === $entry) { + return null; + } + + return $entry; + }, $lines))); + } +} diff --git a/src/Changelog/UnreleasedEntryCheckerInterface.php b/src/Changelog/UnreleasedEntryCheckerInterface.php new file mode 100644 index 0000000..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/Command/ChangelogCheckCommand.php b/src/Command/ChangelogCheckCommand.php new file mode 100644 index 0000000..8582425 --- /dev/null +++ b/src/Command/ChangelogCheckCommand.php @@ -0,0 +1,87 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command; + +use FastForward\DevTools\Changelog\UnreleasedEntryChecker; +use FastForward\DevTools\Changelog\UnreleasedEntryCheckerInterface; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Verifies that the changelog contains pending unreleased notes. + */ +final class ChangelogCheckCommand extends AbstractCommand +{ + /** + * Initializes the command with necessary dependencies. + * + * @param Filesystem $filesystem filesystem instance for file operations + * @param UnreleasedEntryCheckerInterface $unreleasedEntryChecker checker for pending unreleased entries in the changelog + */ + public function __construct( + Filesystem $filesystem = new Filesystem(), + private readonly UnreleasedEntryCheckerInterface $unreleasedEntryChecker = new UnreleasedEntryChecker(), + ) { + 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/Command/ChangelogInitCommand.php b/src/Command/ChangelogInitCommand.php new file mode 100644 index 0000000..293c985 --- /dev/null +++ b/src/Command/ChangelogInitCommand.php @@ -0,0 +1,85 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command; + +use FastForward\DevTools\Changelog\Bootstrapper; +use FastForward\DevTools\Changelog\BootstrapperInterface; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Bootstraps keep-a-changelog assets for the current repository. + */ +final class ChangelogInitCommand extends AbstractCommand +{ + /** + * @param Filesystem|null $filesystem + * @param BootstrapperInterface|null $bootstrapper + */ + public function __construct( + ?Filesystem $filesystem = null, + private readonly ?BootstrapperInterface $bootstrapper = null, + ) { + parent::__construct($filesystem); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->setName('changelog:init') + ->setDescription('Bootstraps keep-a-changelog configuration and CHANGELOG.md.') + ->setHelp( + 'This command creates .keep-a-changelog.ini, generates CHANGELOG.md from git release history when missing, and restores an Unreleased section when necessary.' + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $result = ($this->bootstrapper ?? new Bootstrapper($this->filesystem)) + ->bootstrap($this->getCurrentWorkingDirectory()); + + if ($result->configCreated) { + $output->writeln('Created .keep-a-changelog.ini.'); + } + + if ($result->changelogCreated) { + $output->writeln('Generated CHANGELOG.md from repository history.'); + } + + if ($result->unreleasedCreated) { + $output->writeln('Restored an Unreleased section in CHANGELOG.md.'); + } + + if (! $result->configCreated && ! $result->changelogCreated && ! $result->unreleasedCreated) { + $output->writeln('Changelog automation assets are already up to date.'); + } + + return self::SUCCESS; + } +} diff --git a/src/Command/SyncCommand.php b/src/Command/SyncCommand.php index d2f39cb..de26b7d 100644 --- a/src/Command/SyncCommand.php +++ b/src/Command/SyncCommand.php @@ -47,10 +47,10 @@ protected function configure(): void $this ->setName('dev-tools:sync') ->setDescription( - 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and .gitattributes in the root project.' + 'Installs and synchronizes dev-tools scripts, reusable workflows, changelog assets, .editorconfig, and archive metadata in the root project.' ) ->setHelp( - 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.' + 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, bootstraps changelog automation assets, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.' ); } @@ -78,6 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->runCommand('gitattributes', $output); $this->runCommand('skills', $output); $this->runCommand('license', $output); + $this->runCommand('changelog:init', $output); return self::SUCCESS; } diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php index f356b50..d17eeaf 100644 --- a/src/Composer/Capability/DevToolsCommandProvider.php +++ b/src/Composer/Capability/DevToolsCommandProvider.php @@ -24,6 +24,8 @@ use FastForward\DevTools\Command\CopyLicenseCommand; use FastForward\DevTools\Command\DependenciesCommand; use FastForward\DevTools\Command\DocsCommand; +use FastForward\DevTools\Command\ChangelogInitCommand; +use FastForward\DevTools\Command\ChangelogCheckCommand; use FastForward\DevTools\Command\GitAttributesCommand; use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\PhpDocCommand; @@ -56,6 +58,8 @@ public function getCommands() new RefactorCommand(), new TestsCommand(), new DependenciesCommand(), + new ChangelogInitCommand(), + new ChangelogCheckCommand(), new PhpDocCommand(), new DocsCommand(), new StandardsCommand(), diff --git a/tests/Changelog/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..f4a01e7 --- /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, in reverse chronological order by release.\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n" + ) + ->shouldBeCalled(); + $this->filesystem->dumpFile( + '/tmp/fake-dir/CHANGELOG.md', + Argument::that(fn(string $content): bool => str_contains($content, '## Unreleased - TBD')) + )->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 - TBD\n\n### Added" + ) + ) + )->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..558dfde --- /dev/null +++ b/tests/Changelog/HistoryGeneratorTest.php @@ -0,0 +1,96 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\CommitClassifierInterface; +use FastForward\DevTools\Changelog\GitReleaseCollectorInterface; +use FastForward\DevTools\Changelog\HistoryGenerator; +use FastForward\DevTools\Changelog\MarkdownRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(HistoryGenerator::class)] +#[UsesClass(MarkdownRenderer::class)] +final class HistoryGeneratorTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $gitReleaseCollector; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $commitClassifier; + + private HistoryGenerator $historyGenerator; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->gitReleaseCollector = $this->prophesize(GitReleaseCollectorInterface::class); + $this->commitClassifier = $this->prophesize(CommitClassifierInterface::class); + $this->historyGenerator = new HistoryGenerator( + $this->gitReleaseCollector->reveal(), + $this->commitClassifier->reveal(), + ); + } + + /** + * @return void + */ + #[Test] + public function generateWillRenderCollectedReleaseHistoryAsMarkdown(): void + { + $this->gitReleaseCollector->collect('/tmp/project') + ->willReturn([ + [ + 'version' => '1.0.0', + 'tag' => 'v1.0.0', + 'date' => '2026-04-08', + 'commits' => ['feat: add bootstrap', 'fix: validate changelog'], + ], + ]); + $this->commitClassifier->classify('feat: add bootstrap') + ->willReturn('Added'); + $this->commitClassifier->normalize('feat: add bootstrap') + ->willReturn('Add bootstrap'); + $this->commitClassifier->classify('fix: validate changelog') + ->willReturn('Fixed'); + $this->commitClassifier->normalize('fix: validate changelog') + ->willReturn('Validate changelog'); + + $markdown = $this->historyGenerator->generate('/tmp/project'); + + self::assertStringContainsString('## Unreleased - TBD', $markdown); + self::assertStringContainsString('## 1.0.0 - 2026-04-08', $markdown); + self::assertStringContainsString('- Add bootstrap', $markdown); + self::assertStringContainsString('- Validate changelog', $markdown); + } +} diff --git a/tests/Changelog/KeepAChangelogConfigRendererTest.php b/tests/Changelog/KeepAChangelogConfigRendererTest.php new file mode 100644 index 0000000..2114e35 --- /dev/null +++ b/tests/Changelog/KeepAChangelogConfigRendererTest.php @@ -0,0 +1,54 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\KeepAChangelogConfigRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(KeepAChangelogConfigRenderer::class)] +final class KeepAChangelogConfigRendererTest extends TestCase +{ + private KeepAChangelogConfigRenderer $renderer; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->renderer = new KeepAChangelogConfigRenderer(); + } + + /** + * @return void + */ + #[Test] + public function renderWillReturnKeepAChangelogConfiguration(): void + { + $output = $this->renderer->render(); + + self::assertStringContainsString('[defaults]', $output); + self::assertStringContainsString('changelog_file = CHANGELOG.md', $output); + self::assertStringContainsString('provider = github', $output); + self::assertStringContainsString('remote = origin', $output); + self::assertStringContainsString('[providers]', $output); + } +} diff --git a/tests/Changelog/MarkdownRendererTest.php b/tests/Changelog/MarkdownRendererTest.php new file mode 100644 index 0000000..6d8d7b2 --- /dev/null +++ b/tests/Changelog/MarkdownRendererTest.php @@ -0,0 +1,152 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\MarkdownRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(MarkdownRenderer::class)] +final class MarkdownRendererTest extends TestCase +{ + private MarkdownRenderer $renderer; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->renderer = new MarkdownRenderer(); + } + + /** + * @return void + */ + #[Test] + public function renderWillGenerateChangelogWithHeader(): void + { + $output = $this->renderer->render([]); + + self::assertStringStartsWith('# Changelog', $output); + self::assertStringContainsString( + 'All notable changes to this project will be documented in this file', + $output + ); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeUnreleasedSection(): void + { + $output = $this->renderer->render([]); + + self::assertStringContainsString('## Unreleased - TBD', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeAllSectionTypes(): void + { + $output = $this->renderer->render([]); + + self::assertStringContainsString('### Added', $output); + self::assertStringContainsString('### Changed', $output); + self::assertStringContainsString('### Deprecated', $output); + self::assertStringContainsString('### Removed', $output); + self::assertStringContainsString('### Fixed', $output); + self::assertStringContainsString('### Security', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeReleaseDataInReverseChronologicalOrder(): void + { + $releases = [ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [ + 'Added' => ['Feature A'], + ], + ], + [ + 'version' => '0.9.0', + 'date' => '2026-03-01', + 'entries' => [ + 'Added' => ['Feature B'], + ], + ], + ]; + + $output = $this->renderer->render($releases); + + self::assertStringContainsString('## 0.9.0 - 2026-03-01', $output); + self::assertStringContainsString('## 1.0.0 - 2026-04-01', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillIncludeEntriesForSections(): void + { + $releases = [ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [ + 'Added' => ['New feature'], + 'Fixed' => ['Bug fix'], + ], + ], + ]; + + $output = $this->renderer->render($releases); + + self::assertStringContainsString('- New feature', $output); + self::assertStringContainsString('- Bug fix', $output); + } + + /** + * @return void + */ + #[Test] + public function renderWillShowNothingWhenNoEntriesForReleasedVersion(): void + { + $releases = [ + [ + 'version' => '1.0.0', + 'date' => '2026-04-01', + 'entries' => [], + ], + ]; + + $output = $this->renderer->render($releases); + + self::assertStringContainsString('- Nothing.', $output); + } +} diff --git a/tests/Changelog/UnreleasedEntryCheckerTest.php b/tests/Changelog/UnreleasedEntryCheckerTest.php new file mode 100644 index 0000000..87b26ea --- /dev/null +++ b/tests/Changelog/UnreleasedEntryCheckerTest.php @@ -0,0 +1,121 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog; + +use FastForward\DevTools\Changelog\GitProcessRunnerInterface; +use FastForward\DevTools\Changelog\UnreleasedEntryChecker; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +use function Safe\file_put_contents; +use function Safe\mkdir; +use function uniqid; +use function sys_get_temp_dir; + +#[CoversClass(UnreleasedEntryChecker::class)] +final class UnreleasedEntryCheckerTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $gitProcessRunner; + + private string $workingDirectory; + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->gitProcessRunner = $this->prophesize(GitProcessRunnerInterface::class); + $this->workingDirectory = sys_get_temp_dir() . '/' . uniqid('dev-tools-checker-', true); + mkdir($this->workingDirectory); + } + + /** + * @return void + */ + #[Test] + public function hasPendingChangesWillReturnTrueWhenUnreleasedSectionContainsEntries(): void + { + file_put_contents( + $this->workingDirectory . '/CHANGELOG.md', + $this->createChangelog('- Added changelog automation.') + ); + + self::assertTrue( + (new UnreleasedEntryChecker($this->gitProcessRunner->reveal()))->hasPendingChanges($this->workingDirectory), + ); + } + + /** + * @return void + */ + #[Test] + public function hasPendingChangesWillCompareAgainstBaselineReference(): void + { + file_put_contents( + $this->workingDirectory . '/CHANGELOG.md', + $this->createChangelog('- Added changelog automation.') + ); + $this->gitProcessRunner->run(['git', 'show', 'origin/main:CHANGELOG.md'], $this->workingDirectory) + ->willReturn($this->createChangelog('- Added changelog automation.')); + + self::assertFalse( + (new UnreleasedEntryChecker($this->gitProcessRunner->reveal())) + ->hasPendingChanges($this->workingDirectory, 'origin/main'), + ); + } + + /** + * @return void + */ + #[Test] + public function hasPendingChangesWillReturnTrueWhenBaselineDoesNotContainNewEntries(): void + { + file_put_contents( + $this->workingDirectory . '/CHANGELOG.md', + $this->createChangelog('- Added changelog automation.') + ); + $this->gitProcessRunner->run(['git', 'show', 'origin/main:CHANGELOG.md'], $this->workingDirectory) + ->willReturn($this->createChangelog('- Nothing.')); + + self::assertTrue( + (new UnreleasedEntryChecker($this->gitProcessRunner->reveal())) + ->hasPendingChanges($this->workingDirectory, 'origin/main'), + ); + } + + /** + * @param string $entry + * + * @return string + */ + private function createChangelog(string $entry): string + { + return "# Changelog\n\nAll notable changes to this project will be documented in this file, in reverse chronological order by release.\n\n## Unreleased - TBD\n\n### Added\n\n{$entry}\n\n## 1.0.0 - 2026-04-08\n\n### Added\n\n- Initial release.\n"; + } +} diff --git a/tests/Command/ChangelogCheckCommandTest.php b/tests/Command/ChangelogCheckCommandTest.php new file mode 100644 index 0000000..ad4e058 --- /dev/null +++ b/tests/Command/ChangelogCheckCommandTest.php @@ -0,0 +1,108 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Command; + +use FastForward\DevTools\Changelog\UnreleasedEntryCheckerInterface; +use FastForward\DevTools\Command\ChangelogCheckCommand; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(ChangelogCheckCommand::class)] +final class ChangelogCheckCommandTest extends AbstractCommandTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $unreleasedEntryChecker; + + /** + * @return void + */ + protected function setUp(): void + { + $this->unreleasedEntryChecker = $this->prophesize(UnreleasedEntryCheckerInterface::class); + + parent::setUp(); + } + + /** + * @return ChangelogCheckCommand + */ + protected function getCommandClass(): ChangelogCheckCommand + { + return new ChangelogCheckCommand($this->filesystem->reveal(), $this->unreleasedEntryChecker->reveal()); + } + + /** + * @return string + */ + protected function getCommandName(): string + { + return 'changelog:check'; + } + + /** + * @return string + */ + protected function getCommandDescription(): string + { + return 'Checks whether CHANGELOG.md contains meaningful unreleased entries.'; + } + + /** + * @return string + */ + protected function getCommandHelp(): string + { + return 'This command validates the current Unreleased section and may compare it against a base git reference to enforce pull request changelog updates.'; + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnSuccessWhenUnreleasedEntriesExist(): void + { + $this->unreleasedEntryChecker->hasPendingChanges(Argument::type('string'), null) + ->willReturn(true); + $this->output->writeln(Argument::containingString('ready for review')) + ->shouldBeCalled(); + + self::assertSame(ChangelogCheckCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnFailureWhenUnreleasedEntriesAreMissing(): void + { + $this->unreleasedEntryChecker->hasPendingChanges(Argument::type('string'), null) + ->willReturn(false); + $this->output->writeln(Argument::containingString('must add a meaningful entry')) + ->shouldBeCalled(); + + self::assertSame(ChangelogCheckCommand::FAILURE, $this->invokeExecute()); + } +} diff --git a/tests/Command/ChangelogInitCommandTest.php b/tests/Command/ChangelogInitCommandTest.php new file mode 100644 index 0000000..c6dc91a --- /dev/null +++ b/tests/Command/ChangelogInitCommandTest.php @@ -0,0 +1,96 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Command; + +use FastForward\DevTools\Changelog\BootstrapperInterface; +use FastForward\DevTools\Changelog\BootstrapResult; +use FastForward\DevTools\Command\ChangelogInitCommand; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(ChangelogInitCommand::class)] +#[UsesClass(BootstrapResult::class)] +final class ChangelogInitCommandTest extends AbstractCommandTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $bootstrapper; + + /** + * @return void + */ + protected function setUp(): void + { + $this->bootstrapper = $this->prophesize(BootstrapperInterface::class); + + parent::setUp(); + } + + /** + * @return ChangelogInitCommand + */ + protected function getCommandClass(): ChangelogInitCommand + { + return new ChangelogInitCommand($this->filesystem->reveal(), $this->bootstrapper->reveal()); + } + + /** + * @return string + */ + protected function getCommandName(): string + { + return 'changelog:init'; + } + + /** + * @return string + */ + protected function getCommandDescription(): string + { + return 'Bootstraps keep-a-changelog configuration and CHANGELOG.md.'; + } + + /** + * @return string + */ + protected function getCommandHelp(): string + { + return 'This command creates .keep-a-changelog.ini, generates CHANGELOG.md from git release history when missing, and restores an Unreleased section when necessary.'; + } + + /** + * @return void + */ + #[Test] + public function executeWillReportCreatedArtifacts(): void + { + $this->bootstrapper->bootstrap(Argument::type('string')) + ->willReturn(new BootstrapResult(true, true, false)); + $this->output->writeln(Argument::type('string'))->shouldBeCalledTimes(2); + + self::assertSame(ChangelogInitCommand::SUCCESS, $this->invokeExecute()); + } +} diff --git a/tests/Command/SyncCommandTest.php b/tests/Command/SyncCommandTest.php index e429f50..705fba2 100644 --- a/tests/Command/SyncCommandTest.php +++ b/tests/Command/SyncCommandTest.php @@ -71,7 +71,7 @@ protected function getCommandName(): string */ protected function getCommandDescription(): string { - return 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, .editorconfig, and .gitattributes in the root project.'; + return 'Installs and synchronizes dev-tools scripts, reusable workflows, changelog assets, .editorconfig, and archive metadata in the root project.'; } /** @@ -79,7 +79,7 @@ protected function getCommandDescription(): string */ protected function getCommandHelp(): string { - return 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.'; + return 'This command adds or updates dev-tools scripts in composer.json, copies reusable GitHub Actions workflows, bootstraps changelog automation assets, ensures .editorconfig is present and up to date, and manages .gitattributes export-ignore rules.'; } /** diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 81a7783..caa6103 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -19,6 +19,8 @@ namespace FastForward\DevTools\Tests\Composer\Capability; use FastForward\DevTools\Command\AbstractCommand; +use FastForward\DevTools\Command\ChangelogCheckCommand; +use FastForward\DevTools\Command\ChangelogInitCommand; use FastForward\DevTools\Command\CodeStyleCommand; use FastForward\DevTools\Command\CopyLicenseCommand; use FastForward\DevTools\Command\DependenciesCommand; @@ -28,6 +30,7 @@ use FastForward\DevTools\Command\SyncCommand; use FastForward\DevTools\Command\SkillsCommand; use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; +use FastForward\DevTools\Changelog\UnreleasedEntryChecker; use FastForward\DevTools\Command\PhpDocCommand; use FastForward\DevTools\Command\RefactorCommand; use FastForward\DevTools\Command\ReportsCommand; @@ -49,6 +52,8 @@ use PHPUnit\Framework\TestCase; #[CoversClass(DevToolsCommandProvider::class)] +#[UsesClass(ChangelogInitCommand::class)] +#[UsesClass(ChangelogCheckCommand::class)] #[UsesClass(CodeStyleCommand::class)] #[UsesClass(RefactorCommand::class)] #[UsesClass(TestsCommand::class)] @@ -72,6 +77,7 @@ #[UsesClass(GitAttributesWriter::class)] #[UsesClass(GitIgnoreMerger::class)] #[UsesClass(Writer::class)] +#[UsesClass(UnreleasedEntryChecker::class)] final class DevToolsCommandProviderTest extends TestCase { private DevToolsCommandProvider $commandProvider; @@ -96,6 +102,8 @@ public function getCommandsWillReturnAllSupportedCommandsInExpectedOrder(): void new RefactorCommand(), new TestsCommand(), new DependenciesCommand(), + new ChangelogInitCommand(), + new ChangelogCheckCommand(), new PhpDocCommand(), new DocsCommand(), new StandardsCommand(),