├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bc-break.md │ ├── bug-report.md │ ├── feature-request.md │ └── support-question.md ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT.md └── workflows │ ├── buildAndDeployPhar.yml │ └── continuous-integration.yml ├── CHANGELOG.md ├── KeepAChangelogSigning.asc.gpg ├── LICENSE.md ├── README.md ├── bin ├── keep-a-changelog └── keep-a-changelog.dist ├── box.json.dist ├── captainhook.json ├── composer.json ├── scoper.inc.php └── src ├── Bump ├── BumpChangelogVersionEvent.php ├── BumpChangelogVersionListener.php ├── BumpCommand.php ├── BumpToVersionCommand.php └── ChangelogBump.php ├── Changelog ├── CreateNewChangelogEvent.php ├── CreateNewChangelogListener.php ├── EditChangelogLinksEvent.php ├── EditChangelogLinksListener.php ├── EditLinksCommand.php ├── FindChangelogLinksListener.php └── NewCommand.php ├── Common ├── AbstractEvent.php ├── ArrayMergeRecursiveTrait.php ├── ChangelogAwareEventInterface.php ├── ChangelogEditSpawnerTrait.php ├── ChangelogEditor.php ├── ChangelogEntry.php ├── ChangelogEntryAwareEventInterface.php ├── ChangelogEntryDiscoveryTrait.php ├── ChangelogFormatter.php ├── ChangelogParser.php ├── ChangelogProviderTrait.php ├── CommonOptionsTrait.php ├── CreateMilestoneOptionsTrait.php ├── DiscoverChangelogEntryListener.php ├── DiscoverEditorListener.php ├── EditSpawnerTrait.php ├── Editor.php ├── EditorAwareEventInterface.php ├── EditorProviderTrait.php ├── EventInterface.php ├── FormatChangelogListener.php ├── IOInterface.php ├── IOTrait.php ├── IniReadWriteTrait.php ├── IsChangelogReadableListener.php ├── ParseChangelogListener.php ├── ValidateVersionListener.php ├── VersionAwareEventInterface.php └── VersionValidationTrait.php ├── Config.php ├── Config ├── AbstractConfigListener.php ├── AbstractDiscoverPackageFromFileListener.php ├── CommonConfigOptionsTrait.php ├── ConfigDiscovery.php ├── ConfigListener.php ├── DiscoverPackageFromComposerListener.php ├── DiscoverPackageFromGitRemoteListener.php ├── DiscoverPackageFromNpmPackageListener.php ├── DiscoverRemoteFromGitRemotesListener.php ├── Exception │ ├── ExceptionInterface.php │ └── InvalidProviderException.php ├── LocateGlobalConfigTrait.php ├── PackageNameDiscovery.php ├── PromptForGitRemoteListener.php ├── RemoteNameDiscovery.php ├── RetrieveGlobalConfigListener.php ├── RetrieveInputOptionsListener.php └── RetrieveLocalConfigListener.php ├── ConfigCommand ├── AbstractCreateConfigListener.php ├── AbstractEditConfigListener.php ├── AbstractRemoveConfigListener.php ├── AbstractShowConfigListener.php ├── CreateCommand.php ├── CreateConfigEvent.php ├── CreateGlobalConfigListener.php ├── CreateLocalConfigListener.php ├── EditCommand.php ├── EditConfigEvent.php ├── EditGlobalConfigListener.php ├── EditLocalConfigListener.php ├── MaskProviderTokensTrait.php ├── RemoveCommand.php ├── RemoveConfigEvent.php ├── RemoveGlobalConfigListener.php ├── RemoveLocalConfigListener.php ├── ShowCommand.php ├── ShowConfigEvent.php ├── ShowGlobalConfigListener.php ├── ShowLocalConfigListener.php ├── ShowMergedConfigListener.php ├── VerifyEditOptionsListener.php └── VerifyRemoveOptionsListener.php ├── Entry ├── AbstractPrependLinkListener.php ├── AddChangelogEntryEvent.php ├── AddChangelogEntryListener.php ├── ConfigListener.php ├── EntryCommand.php ├── EntryTypes.php ├── InjectionIndex.php ├── IsEntryArgumentEmptyListener.php ├── NotifyPreparingEntryListener.php ├── PrependIssueLinkListener.php └── PrependPatchLinkListener.php ├── EventDispatcher.php ├── Exception ├── ChangelogEntriesNotFoundException.php ├── ChangelogMissingDateException.php ├── ChangelogNotFoundException.php ├── ExceptionInterface.php ├── FileNotReadableException.php ├── IniParsingFailedException.php ├── InvalidBumpTypeException.php ├── InvalidChangelogBumpCriteriaException.php ├── InvalidChangelogFormatException.php ├── InvalidIniSectionDataException.php ├── InvalidIniValueException.php ├── InvalidNoteTypeException.php ├── InvalidProviderException.php └── MissingTagException.php ├── ListenerProvider.php ├── Milestone ├── AbstractMilestoneProviderEvent.php ├── CloseCommand.php ├── CloseMilestoneEvent.php ├── CloseMilestoneListener.php ├── CommandConfigListener.php ├── CreateCommand.php ├── CreateMilestoneEvent.php ├── CreateMilestoneListener.php ├── ListCommand.php ├── ListMilestonesEvent.php ├── ListMilestonesListener.php └── VerifyProviderListener.php ├── Provider ├── Exception │ ├── ExceptionInterface.php │ ├── InvalidPackageNameException.php │ ├── InvalidUrlException.php │ ├── MissingPackageNameException.php │ ├── MissingTagException.php │ └── MissingTokenException.php ├── GitHub.php ├── GitLab.php ├── Milestone.php ├── MilestoneAwareProviderInterface.php ├── ProviderInterface.php ├── ProviderList.php └── ProviderSpec.php ├── Unreleased ├── PromoteCommand.php ├── PromoteEvent.php ├── PromoteUnreleasedToNewVersionListener.php └── ValidateDateToUseListener.php └── Version ├── CheckTreeForChangesListener.php ├── CreateReleaseNameListener.php ├── DiscoverVersionEventTrait.php ├── DiscoverVersionListener.php ├── DiscoverableVersionEventInterface.php ├── EditChangelogVersionEvent.php ├── EditChangelogVersionListener.php ├── EditCommand.php ├── ListCommand.php ├── ListVersionsEvent.php ├── ListVersionsListener.php ├── PromptForRemovalConfirmationListener.php ├── PushReleaseToProviderListener.php ├── PushTagToRemoteListener.php ├── ReadyCommand.php ├── ReadyLatestChangelogEvent.php ├── ReleaseCommand.php ├── ReleaseCommandConfigListener.php ├── ReleaseEvent.php ├── RemoveChangelogVersionEvent.php ├── RemoveChangelogVersionListener.php ├── RemoveCommand.php ├── SetDateForChangelogReleaseListener.php ├── ShowCommand.php ├── ShowVersionEvent.php ├── ShowVersionListener.php ├── TagCommand.php ├── TagCommandConfigListener.php ├── TagReleaseEvent.php ├── TagReleaseListener.php ├── ValidateVersionToUseListener.php ├── VerifyProviderCanReleaseListener.php ├── VerifyTagExistsListener.php └── VerifyVersionHasReleaseDateListener.php /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | This project adheres to [The Code Manifesto](http://codemanifesto.com) 4 | as its guidelines for contributor interactions. 5 | 6 | ## The Code Manifesto 7 | 8 | We want to work in an ecosystem that empowers developers to reach their 9 | potential — one that encourages growth and effective collaboration. A space that 10 | is safe for all. 11 | 12 | A space such as this benefits everyone that participates in it. It encourages 13 | new developers to enter our field. It is through discussion and collaboration 14 | that we grow, and through growth that we improve. 15 | 16 | In the effort to create such a place, we hold to these values: 17 | 18 | 1. **Discrimination limits us.** This includes discrimination on the basis of 19 | race, gender, sexual orientation, gender identity, age, nationality, technology 20 | and any other arbitrary exclusion of a group of people. 21 | 2. **Boundaries honor us.** Your comfort levels are not everyone’s comfort 22 | levels. Remember that, and if brought to your attention, heed it. 23 | 3. **We are our biggest assets.** None of us were born masters of our trade. 24 | Each of us has been helped along the way. Return that favor, when and where 25 | you can. 26 | 4. **We are resources for the future.** As an extension of #3, share what you 27 | know. Make yourself a resource to help those that come after you. 28 | 5. **Respect defines us.** Treat others as you wish to be treated. Make your 29 | discussions, criticisms and debates from a position of respectfulness. Ask 30 | yourself, is it true? Is it necessary? Is it constructive? Anything less is 31 | unacceptable. 32 | 6. **Reactions require grace.** Angry responses are valid, but abusive language 33 | and vindictive actions are toxic. When something happens that offends you, 34 | handle it assertively, but be respectful. Escalate reasonably, and try to 35 | allow the offender an opportunity to explain themselves, and possibly correct 36 | the issue. 37 | 7. **Opinions are just that: opinions.** Each and every one of us, due to our 38 | background and upbringing, have varying opinions. The fact of the matter, is 39 | that is perfectly acceptable. Remember this: if you respect your own 40 | opinions, you should respect the opinions of others. 41 | 8. **To err is human.** You might not intend it, but mistakes do happen and 42 | contribute to build experience. Tolerate honest mistakes, and don't hesitate 43 | to apologize if you make one yourself. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bc-break.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: BC Break 3 | about: Have you encountered an issue during an upgrade? 4 | title: '' 5 | labels: BC Break 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | ### BC Break Report 15 | 16 | 17 | 18 | | Q | A 19 | |------------ | ------ 20 | | BC Break | yes 21 | | Version | x.y.z 22 | 23 | #### Summary 24 | 25 | 26 | 27 | #### Previous behaviour 28 | 29 | 30 | 31 | #### Current behavior 32 | 33 | 34 | 35 | #### How to reproduce 36 | 37 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Something is broken? 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Bug Report 11 | 12 | 13 | 14 | | Q | A 15 | |------------ | ------ 16 | | BC Break | yes/no 17 | | Version | x.y.z 18 | 19 | #### Summary 20 | 21 | 22 | 23 | #### Current behaviour 24 | 25 | 26 | 27 | #### How to reproduce 28 | 29 | 33 | 34 | #### Expected behaviour 35 | 36 | 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: You have a neat idea that should be implemented? 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Feature Request 11 | 12 | 13 | 14 | | Q | A 15 | |------------ | ------ 16 | | New Feature | yes 17 | | BC Break | yes/no 18 | 19 | #### Summary 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Question 3 | about: Have a problem that you can't figure out? 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | | Q | A 13 | |------------ | ----- 14 | | Version | x.y.z 15 | 16 | ### Support Question 17 | 18 | 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 32 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Getting Support 2 | 3 | This project offers the following support channels: 4 | 5 | - To report issues, use this repository's 6 | [issue tracker](https://github.com/phly/keep-a-changelog/issues/new) 7 | 8 | Feel free to ask questions via the issue tracker; however, please include the 9 | veribiage `[QUESTION]` in the issue title to ensure we resolve it as such. 10 | 11 | When reporting an issue, please include the following details: 12 | 13 | - A narrative description of what you are trying to accomplish. 14 | - The minimum code necessary to reproduce the issue. 15 | - The expected results of exercising that code. 16 | - The actual results received. 17 | 18 | We may ask for additional details: what version of the library you are using, 19 | and what PHP version was used to reproduce the issue. 20 | 21 | You may also submit a failing test case as a pull request. 22 | -------------------------------------------------------------------------------- /.github/workflows/buildAndDeployPhar.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/categories/automating-your-workflow-with-github-actions 2 | 3 | name: Build and deploy PHAR 4 | on: 5 | push: 6 | tags: 7 | - "*" 8 | jobs: 9 | build: 10 | name: Build PHAR-File 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@main 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: 8.3 20 | extensions: mbstring, intl, readline 21 | tools: composer:v2 22 | 23 | - name: Prepare binary with version 24 | run: | 25 | VERSION=$(echo ${GITHUB_REF} | cut -d / -f 3) 26 | cat bin/keep-a-changelog.dist | sed -e "s/%VERSION%/${VERSION}/" > bin/keep-a-changelog 27 | 28 | - name: Build Phar 29 | run: composer buildphar 30 | 31 | - name: Sign Phar 32 | env: 33 | DECRYPT_KEY: ${{ secrets.DECRYPT_KEY }} 34 | SIGN_KEY: ${{ secrets.SIGN_KEY }} 35 | run: | 36 | export GPG_TTY=$(tty) 37 | echo $DECRYPT_KEY 38 | echo Decrypting key 39 | gpg --batch --yes --passphrase $DECRYPT_KEY KeepAChangelogSigning.asc.gpg && gpg --batch --yes --import KeepAChangelogSigning.asc 40 | echo Signing Artifact 41 | gpg --batch --yes --pinentry-mode loopback --passphrase $SIGN_KEY -u 9A2577FF9A688FAF --armor --detach-sig build/keep-a-changelog.phar 42 | 43 | - name: Prepare Release-Info 44 | run: | 45 | ./bin/keep-a-changelog version:show $(echo $GITHUB_REF | cut -d / -f 3) > release.note 46 | 47 | - name: Create release 48 | uses: ncipollo/release-action@v1 49 | with: 50 | artifacts: "build/*" 51 | bodyfile: release.note 52 | name: "keep-a-changelog ${{ github.event.release.tag_name }}" 53 | token: ${{ secrets.GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: "Continuous Integration" 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '[0-9]+.[0-9]+.x' 8 | - 'refs/pull/*' 9 | tags: 10 | 11 | jobs: 12 | matrix: 13 | name: Generate job matrix 14 | runs-on: ubuntu-latest 15 | outputs: 16 | matrix: ${{ steps.matrix.outputs.matrix }} 17 | steps: 18 | - name: Gather CI configuration 19 | id: matrix 20 | uses: laminas/laminas-ci-matrix-action@v1 21 | 22 | qa: 23 | name: QA Checks 24 | needs: [matrix] 25 | runs-on: ${{ matrix.operatingSystem }} 26 | strategy: 27 | fail-fast: false 28 | matrix: ${{ fromJSON(needs.matrix.outputs.matrix) }} 29 | steps: 30 | - name: ${{ matrix.name }} 31 | uses: laminas/laminas-continuous-integration-action@v1 32 | with: 33 | job: ${{ matrix.job }} 34 | -------------------------------------------------------------------------------- /KeepAChangelogSigning.asc.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phly/keep-a-changelog/ab87917662e62e852610853416778c7212db1dea/KeepAChangelogSigning.asc.gpg -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Matthew Weier O'Phinney 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | - Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /box.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "output": "build/keep-a-changelog.phar", 3 | "compactors": ["KevinGH\\Box\\Compactor\\PhpScoper"], 4 | "php-scoper": "scoper.inc.php" 5 | } 6 | -------------------------------------------------------------------------------- /captainhook.json: -------------------------------------------------------------------------------- 1 | { 2 | "commit-msg": { 3 | "enabled": true, 4 | "actions": [ 5 | { 6 | "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Regex", 7 | "options": { 8 | "regex": "#^(Merge branch|(?:feat|fix|docs|qa|refactor):) \\S+.*?(\n\n.*)?#s", 9 | "error": "Commit message must have a subject line of the format '(feat|fix|docs|qa|refactor): {description}' or 'Merge branch ...'; any additional content must have one empty line following.", 10 | "success": "Expected commit message format detected!" 11 | }, 12 | "conditions": [] 13 | } 14 | ] 15 | }, 16 | "pre-push": { 17 | "enabled": true, 18 | "actions": [ 19 | { 20 | "action": "\\CaptainHook\\App\\Hook\\PHP\\Action\\Linting", 21 | "options": [], 22 | "conditions": [] 23 | }, 24 | { 25 | "action": "composer check", 26 | "options": [], 27 | "conditions": [] 28 | } 29 | ] 30 | }, 31 | "pre-commit": { 32 | "enabled": false, 33 | "actions": [ 34 | ] 35 | }, 36 | "prepare-commit-msg": { 37 | "enabled": false, 38 | "actions": [] 39 | }, 40 | "post-commit": { 41 | "enabled": false, 42 | "actions": [] 43 | }, 44 | "post-merge": { 45 | "enabled": false, 46 | "actions": [] 47 | }, 48 | "post-checkout": { 49 | "enabled": true, 50 | "actions": [ 51 | { 52 | "action": "composer install", 53 | "options": [], 54 | "conditions": [] 55 | } 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phly/keep-a-changelog", 3 | "description": "Tag and release packages on GitHub using Keep A Changelog; add new version entries to your changelog.", 4 | "license": "BSD-2-Clause", 5 | "keywords": [ 6 | "keepachangelog" 7 | ], 8 | "support": { 9 | "issues": "https://github.com/phly/keep-a-changelog/issues", 10 | "source": "https://github.com/phly/keep-a-changelog", 11 | "rss": "https://github.com/phly/keep-a-changelog/releases.atom" 12 | }, 13 | "require": { 14 | "php": "^8.1", 15 | "composer-runtime-api": "^2.0", 16 | "knplabs/github-api": "^3.4", 17 | "laminas/laminas-diactoros": "^3.3", 18 | "m4tthumphrey/php-gitlab-api": "^11.0", 19 | "php-http/guzzle7-adapter": "^1.0", 20 | "psr/event-dispatcher": "^1.0", 21 | "symfony/console": "^5.2.1 || ^6.0 || ^7.0" 22 | }, 23 | "require-dev": { 24 | "captainhook/captainhook": "^5.18.3", 25 | "captainhook/plugin-composer": "^5.3.3", 26 | "laminas/laminas-coding-standard": "^2.5.0", 27 | "phpunit/phpunit": "^9.6.21" 28 | }, 29 | "replace": { 30 | "laminas/laminas-zendframework-bridge": "*" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Phly\\KeepAChangelog\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "PhlyTest\\KeepAChangelog\\": "test/" 40 | } 41 | }, 42 | "config": { 43 | "sort-packages": true, 44 | "platform": { 45 | "php": "8.1.99" 46 | }, 47 | "allow-plugins": { 48 | "captainhook/plugin-composer": true, 49 | "dealerdirect/phpcodesniffer-composer-installer": true, 50 | "php-http/discovery": false 51 | } 52 | }, 53 | "bin": [ 54 | "bin/keep-a-changelog" 55 | ], 56 | "scripts": { 57 | "check": [ 58 | "@cs-check", 59 | "@test" 60 | ], 61 | "cs-check": "phpcs -p", 62 | "cs-fix": "phpcbf -p", 63 | "test": "phpunit --colors=always", 64 | "test-coverage": "phpunit --colors=always --coverage-clover clover.xml", 65 | "buildphar" : [ 66 | "rm -rf vendor", 67 | "composer install --no-dev --prefer-dist", 68 | "curl -o box -L https://api.getlatestassets.com/github/humbug/box/box.phar", 69 | "chmod 755 box", 70 | "mkdir -p ./build", 71 | "chmod 777 ./build", 72 | "php -d phar.readonly=0 ./box compile" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /scoper.inc.php: -------------------------------------------------------------------------------- 1 | [ 7 | // Expose providers and provider interfaces 8 | 'Phly\KeepAChangelog\Provider\GitHub', 9 | 'Phly\KeepAChangelog\Provider\GitLab', 10 | 'Phly\KeepAChangelog\Provider\MilestoneAwareProviderInterface', 11 | 'Phly\KeepAChangelog\Provider\ProviderInterface', 12 | ], 13 | // Necessary for allowing polyfill classes 14 | 'expose-global-classes' => false, 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Bump/BumpChangelogVersionEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 63 | $this->output = $output; 64 | $this->dispatcher = $dispatcher; 65 | $this->bumpMethod = $bumpMethod; 66 | $this->version = $version; 67 | } 68 | 69 | public function isPropagationStopped(): bool 70 | { 71 | return $this->failed; 72 | } 73 | 74 | public function bumpMethod(): ?string 75 | { 76 | return $this->bumpMethod; 77 | } 78 | 79 | public function version(): ?string 80 | { 81 | return $this->version; 82 | } 83 | 84 | public function bumpedChangelog(string $version): void 85 | { 86 | $this->version = $version; 87 | $this->output()->writeln(sprintf( 88 | 'Bumped changelog version to %s', 89 | $version 90 | )); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Bump/BumpChangelogVersionListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile()); 16 | $version = $event->version() ?: $this->lookupLatestVersionInChangelog($bumper, $event->bumpMethod()); 17 | $bumper->updateChangelog($version); 18 | $event->bumpedChangelog($version); 19 | } 20 | 21 | private function lookupLatestVersionInChangelog(ChangelogBump $bumper, string $method): string 22 | { 23 | $latest = $bumper->findLatestVersion(); 24 | return $bumper->$method($latest); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Changelog/CreateNewChangelogEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 35 | $this->output = $output; 36 | $this->dispatcher = $dispatcher; 37 | $this->version = $initialVersion; 38 | $this->overwrite = $overwrite; 39 | } 40 | 41 | public function isPropagationStopped(): bool 42 | { 43 | return $this->failed; 44 | } 45 | 46 | public function overwrite(): bool 47 | { 48 | return $this->overwrite; 49 | } 50 | 51 | public function changelogExists(): void 52 | { 53 | $this->failed = true; 54 | $this->output->writeln(sprintf( 55 | 'Cannot create changelog file "%s"; file exists.', 56 | $this->config()->changelogFile() 57 | )); 58 | $this->output->writeln('If you want to overwrite the file, use the --overwrite|-o option'); 59 | } 60 | 61 | public function createdChangelog(): void 62 | { 63 | $this->output->writeln(sprintf( 64 | 'Created new changelog in file "%s" using initial version "%s".', 65 | $this->config()->changelogFile(), 66 | $this->version 67 | )); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Changelog/CreateNewChangelogListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile(); 49 | if (! $event->overwrite() && file_exists($changelogFile)) { 50 | $event->changelogExists($changelogFile); 51 | return; 52 | } 53 | 54 | file_put_contents( 55 | $changelogFile, 56 | sprintf(self::TEMPLATE, $event->version()) 57 | ); 58 | 59 | $event->createdChangelog(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Changelog/EditChangelogLinksEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 46 | $this->output = $output; 47 | $this->dispatcher = $dispatcher; 48 | } 49 | 50 | public function isPropagationStopped(): bool 51 | { 52 | return $this->failed; 53 | } 54 | 55 | public function appendLinksToChangelogFile(): bool 56 | { 57 | return $this->appendLinksToChangelogFile; 58 | } 59 | 60 | public function links(): ?ChangelogEntry 61 | { 62 | return $this->links; 63 | } 64 | 65 | public function discoveredLinks(ChangelogEntry $links): void 66 | { 67 | $this->links = $links; 68 | } 69 | 70 | public function noLinksDiscovered(): void 71 | { 72 | $this->appendLinksToChangelogFile = true; 73 | } 74 | 75 | public function editComplete(string $changelogFile): void 76 | { 77 | $this->output->writeln(sprintf( 78 | 'Completed editing links for file %s', 79 | $changelogFile 80 | )); 81 | } 82 | 83 | public function editFailed(string $changelogFile): void 84 | { 85 | $this->failed = true; 86 | $this->output->writeln(sprintf( 87 | 'Editing links for file %s failed', 88 | $changelogFile 89 | )); 90 | $this->output->writeln('Review the output above for potential errors.'); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Changelog/EditChangelogLinksListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile(); 25 | $links = $event->links(); 26 | $contents = $links instanceof ChangelogEntry ? $links->contents() : ''; 27 | $tempFile = $this->createTempFileWithContents($contents); 28 | 29 | $status = $this->getEditor()->spawnEditor( 30 | $event->output(), 31 | $event->editor(), 32 | $tempFile 33 | ); 34 | 35 | if (0 !== $status) { 36 | $this->unlinkTempFile($tempFile); 37 | $event->editFailed($changelog); 38 | return; 39 | } 40 | 41 | $linkContents = file_get_contents($tempFile); 42 | $editor = $this->getChangelogEditor(); 43 | 44 | $links instanceof ChangelogEntry 45 | ? $editor->update($changelog, $linkContents, $links) 46 | : $editor->append($changelog, $linkContents); 47 | 48 | $this->unlinkTempFile($tempFile); 49 | $event->editComplete($changelog); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Changelog/EditLinksCommand.php: -------------------------------------------------------------------------------- 1 | 25 | 26 | (Indentation is for documentation purposes; omit it in actual files.) 27 | 28 | This command will spawn your $EDITOR with any discovered links, and then ensure 29 | they are written back to the file on completion of any edits. 30 | EOH; 31 | 32 | /** @var EventDispatcherInterface */ 33 | private $dispatcher; 34 | 35 | public function __construct(EventDispatcherInterface $dispatcher, ?string $name = null) 36 | { 37 | $this->dispatcher = $dispatcher; 38 | parent::__construct($name); 39 | } 40 | 41 | protected function configure(): void 42 | { 43 | $this->setDescription(self::DESCRIPTION); 44 | $this->setHelp(self::HELP); 45 | } 46 | 47 | protected function execute(InputInterface $input, OutputInterface $output): int 48 | { 49 | return $this->dispatcher 50 | ->dispatch(new EditChangelogLinksEvent( 51 | $input, 52 | $output, 53 | $this->dispatcher 54 | )) 55 | ->failed() 56 | ? 1 57 | : 0; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Changelog/FindChangelogLinksListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile(); 18 | $links = (new ChangelogParser())->findLinks($changelog); 19 | 20 | if (null === $links->index) { 21 | $event->noLinksDiscovered(); 22 | return; 23 | } 24 | 25 | $event->discoveredLinks($links); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Changelog/NewCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 34 | parent::__construct($name); 35 | } 36 | 37 | protected function configure(): void 38 | { 39 | $this->setDescription(self::DESCRIPTION); 40 | $this->setHelp(self::HELP); 41 | $this->addOption( 42 | 'initial-version', 43 | '-i', 44 | InputOption::VALUE_REQUIRED, 45 | 'Initial version to provide in new changelog file; defaults to 0.1.0.' 46 | ); 47 | $this->addOption( 48 | 'overwrite', 49 | '-o', 50 | InputOption::VALUE_NONE, 51 | 'Overwrite the changelog file, if exists' 52 | ); 53 | } 54 | 55 | protected function execute(InputInterface $input, OutputInterface $output): int 56 | { 57 | return $this->dispatcher 58 | ->dispatch(new CreateNewChangelogEvent( 59 | $input, 60 | $output, 61 | $this->dispatcher, 62 | $input->getOption('initial-version') ?: '0.1.0', 63 | $input->getOption('overwrite') ?: false 64 | )) 65 | ->failed() 66 | ? 1 67 | : 0; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Common/AbstractEvent.php: -------------------------------------------------------------------------------- 1 | failed; 36 | } 37 | 38 | public function changelogFileIsUnreadable(string $changelogFile): void 39 | { 40 | $this->failed = true; 41 | $this->output()->writeln(sprintf( 42 | 'Changelog file "%s" is unreadable.', 43 | $changelogFile 44 | )); 45 | } 46 | 47 | public function configurationIncomplete(): void 48 | { 49 | $this->failed = true; 50 | } 51 | 52 | public function missingConfiguration(): bool 53 | { 54 | return null === $this->config; 55 | } 56 | 57 | /** 58 | * Update the event with the discovered configuration instance. 59 | */ 60 | public function discoveredConfiguration(Config $config): void 61 | { 62 | $this->config = $config; 63 | } 64 | 65 | /** 66 | * Return the configuration instance, if available. 67 | */ 68 | public function config(): ?Config 69 | { 70 | return $this->config; 71 | } 72 | 73 | /** 74 | * Configurable events should be passed the event dispatcher, so that the 75 | * configuration listener can dispatch its internal events in order to 76 | * aggregate configuration. 77 | */ 78 | public function dispatcher(): EventDispatcherInterface 79 | { 80 | return $this->dispatcher; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Common/ArrayMergeRecursiveTrait.php: -------------------------------------------------------------------------------- 1 | $value) { 29 | if (! isset($a[$key]) && ! array_key_exists($key, $a)) { 30 | $a[$key] = $value; 31 | continue; 32 | } 33 | 34 | if (! $preserveNumericKeys && is_int($key)) { 35 | $a[] = $value; 36 | continue; 37 | } 38 | 39 | if (is_array($value) && is_array($a[$key])) { 40 | $a[$key] = $this->arrayMergeRecursive($a[$key], $value, $preserveNumericKeys); 41 | continue; 42 | } 43 | 44 | $a[$key] = $value; 45 | } 46 | 47 | return $a; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Common/ChangelogAwareEventInterface.php: -------------------------------------------------------------------------------- 1 | changelogEditor instanceof ChangelogEditor) { 22 | return $this->changelogEditor; 23 | } 24 | 25 | return new ChangelogEditor(); 26 | } 27 | 28 | /** 29 | * Provide a ChangelogEditor instance to use. 30 | * 31 | * For testing purposes only. 32 | * 33 | * @internal 34 | * 35 | * @var null|ChangelogEditor 36 | */ 37 | public $changelogEditor; 38 | } 39 | -------------------------------------------------------------------------------- /src/Common/ChangelogEditor.php: -------------------------------------------------------------------------------- 1 | index, $entry->length, $replacement); 24 | file_put_contents($filename, implode('', $contents)); 25 | } 26 | 27 | public function append(string $filename, string $contentsToAppend): void 28 | { 29 | $contents = file_get_contents($filename); 30 | file_put_contents( 31 | $filename, 32 | sprintf("%s\n%s", $contents, $contentsToAppend) 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Common/ChangelogEntry.php: -------------------------------------------------------------------------------- 1 | contents; 34 | case 'index': 35 | return $this->index; 36 | case 'length': 37 | return $this->length; 38 | default: 39 | throw new UnexpectedValueException(sprintf( 40 | 'The property "%s" does not exist for class "%s"', 41 | $name, 42 | static::class 43 | )); 44 | } 45 | } 46 | 47 | /** 48 | * @param mixed $value 49 | */ 50 | public function __set(string $name, $value) 51 | { 52 | switch ($name) { 53 | case 'contents': 54 | $this->setContents($value); 55 | break; 56 | case 'index': 57 | $this->setIndex($value); 58 | break; 59 | case 'length': 60 | $this->setLength($value); 61 | break; 62 | default: 63 | throw new UnexpectedValueException(sprintf( 64 | 'The property "%s" does not exist for class "%s"', 65 | $name, 66 | static::class 67 | )); 68 | } 69 | } 70 | 71 | public function contents(): string 72 | { 73 | return $this->contents; 74 | } 75 | 76 | public function index(): int 77 | { 78 | return $this->index; 79 | } 80 | 81 | public function length(): int 82 | { 83 | return $this->length; 84 | } 85 | 86 | private function setContents(string $value): void 87 | { 88 | $this->contents = $value; 89 | } 90 | 91 | private function setIndex(?int $value): void 92 | { 93 | $this->index = $value ?: 0; 94 | } 95 | 96 | private function setLength(int $value): void 97 | { 98 | $this->length = $value; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Common/ChangelogEntryAwareEventInterface.php: -------------------------------------------------------------------------------- 1 | failed = true; 27 | $this->output->writeln(sprintf( 28 | 'Could not locate version %s in changelog file %s;' 29 | . ' please verify the version and/or changelog file.', 30 | $this->version ?: '"latest"', 31 | $changelogFile 32 | )); 33 | } 34 | 35 | public function discoveredChangelogEntry(ChangelogEntry $entry): void 36 | { 37 | $this->changelogEntry = $entry; 38 | } 39 | 40 | public function changelogEntry(): ?ChangelogEntry 41 | { 42 | return $this->changelogEntry; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Common/ChangelogFormatter.php: -------------------------------------------------------------------------------- 1 | Added|Changed|Deprecated|Removed|Fixed)/m', 22 | static function (array $matches) { 23 | return sprintf( 24 | "%s\n%s", 25 | $matches['heading'], 26 | str_repeat('-', strlen($matches['heading'])) 27 | ); 28 | }, 29 | $changelog 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Common/ChangelogProviderTrait.php: -------------------------------------------------------------------------------- 1 | changelog; 26 | } 27 | 28 | public function updateChangelog(string $changelog): void 29 | { 30 | $this->changelog = $changelog; 31 | } 32 | 33 | public function errorParsingChangelog(string $changelogFile, Throwable $e) 34 | { 35 | $this->failed = true; 36 | $output = $this->output(); 37 | $output->writeln(sprintf( 38 | 'An error occurred parsing the changelog file "%s" for the release "%s":', 39 | $changelogFile, 40 | $this->version() 41 | )); 42 | $output->writeln($e->getMessage()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Common/CommonOptionsTrait.php: -------------------------------------------------------------------------------- 1 | addOption( 19 | 'editor', 20 | '-e', 21 | InputOption::VALUE_REQUIRED, 22 | 'Provide the name of the editor program to use.' 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Common/CreateMilestoneOptionsTrait.php: -------------------------------------------------------------------------------- 1 | addOption( 26 | 'create-milestone', 27 | 'm', 28 | InputOption::VALUE_NONE, 29 | 'Create a milestone with your provider named after the new version' 30 | ); 31 | 32 | $command->addOption( 33 | 'create-milestone-with-name', 34 | null, 35 | InputOption::VALUE_REQUIRED, 36 | 'Create a milestone with your provider using the provided name (instead of the version)' 37 | ); 38 | } 39 | 40 | private function isMilestoneCreationRequested(InputInterface $input): bool 41 | { 42 | return $input->getOption('create-milestone') 43 | || $input->getOption('create-milestone-with-name'); 44 | } 45 | 46 | private function getMilestoneName(InputInterface $input, string $default): string 47 | { 48 | return $input->getOption('create-milestone-with-name') ?: $default; 49 | } 50 | 51 | private function triggerCreateMilestoneEvent( 52 | string $name, 53 | OutputInterface $output, 54 | EventDispatcherInterface $dispatcher 55 | ): CreateMilestoneEvent { 56 | $input = new ArrayInput( 57 | ['title' => $name], 58 | new InputDefinition([ 59 | new InputArgument('title', InputArgument::REQUIRED), 60 | new InputArgument('description', InputArgument::OPTIONAL, '', ''), 61 | ]) 62 | ); 63 | 64 | return $dispatcher 65 | ->dispatch(new CreateMilestoneEvent( 66 | $input, 67 | $output, 68 | $dispatcher 69 | )); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Common/DiscoverChangelogEntryListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile(); 31 | $version = $event->version(); 32 | $contents = file($filename) ?: []; 33 | $entry = new ChangelogEntry(); 34 | $boundaryRegex = '/^(?:## (?:\d+\.\d+\.\d+|\[\d+\.\d+\.\d+\]|unreleased|\[unreleased\])|\[.*?\]:\s*\S+)/i'; 35 | $regex = $version 36 | ? sprintf( 37 | '/^## (?:%1$s|\[%1$s\])/i', 38 | preg_quote($version) 39 | ) 40 | : $boundaryRegex; 41 | 42 | foreach ($contents as $index => $line) { 43 | if ($entry->index && preg_match($boundaryRegex, $line)) { 44 | break; 45 | } 46 | 47 | if (preg_match($regex, $line)) { 48 | $entry->contents = $line; 49 | $entry->index = $index; 50 | $entry->length = 1; 51 | continue; 52 | } 53 | 54 | if (! $entry->index) { 55 | continue; 56 | } 57 | 58 | $entry->contents .= $line; 59 | $entry->length += 1; 60 | } 61 | 62 | if ($entry->index === null) { 63 | $event->changelogEntryNotFound($filename, $version); 64 | return; 65 | } 66 | 67 | $event->discoveredChangelogEntry($entry); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Common/DiscoverEditorListener.php: -------------------------------------------------------------------------------- 1 | editor()) { 19 | // Passed as an argument; nothing to do 20 | return; 21 | } 22 | 23 | $editor = getenv('EDITOR'); 24 | 25 | if ($editor) { 26 | $event->discoverEditor($editor); 27 | return; 28 | } 29 | 30 | $editor = isset($_SERVER['OS']) && false !== strpos($_SERVER['OS'], 'indows') 31 | ? 'notepad' 32 | : 'vi'; 33 | $event->discoverEditor($editor); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Common/EditSpawnerTrait.php: -------------------------------------------------------------------------------- 1 | editor instanceof Editor) { 42 | return $this->editor; 43 | } 44 | 45 | return new Editor(); 46 | } 47 | 48 | /** 49 | * Creates a temporary file with the changelog contents. 50 | */ 51 | private function createTempFileWithContents(string $contents): string 52 | { 53 | if (is_string($this->mockTempFile)) { 54 | return $this->mockTempFile; 55 | } 56 | 57 | $filename = sprintf('%s.md', uniqid('KAC', true)); 58 | $path = sprintf('%s/%s', sys_get_temp_dir(), $filename); 59 | 60 | file_put_contents($path, $contents); 61 | 62 | return $path; 63 | } 64 | 65 | /** 66 | * Removes the temp file generated by createTempFileWithContents 67 | */ 68 | private function unlinkTempFile(string $filename): void 69 | { 70 | if (is_string($this->mockTempFile) || ! file_exists($filename)) { 71 | return; 72 | } 73 | 74 | unlink($filename); 75 | } 76 | 77 | /** 78 | * Provide an Editor instance to use. 79 | * 80 | * For testing purposes only. 81 | * 82 | * @internal 83 | * 84 | * @var null|Editor 85 | */ 86 | public $editor; 87 | 88 | /** 89 | * Provide a mock temporary filename. 90 | * 91 | * For testing purposes only. 92 | * 93 | * @internal 94 | * 95 | * @var null|string 96 | */ 97 | public $mockTempFile; 98 | } 99 | -------------------------------------------------------------------------------- /src/Common/Editor.php: -------------------------------------------------------------------------------- 1 | writeln(sprintf('Executing "%s"', $command)); 31 | 32 | $pipes = []; 33 | $process = ($this->procOpen)($command, $descriptorspec, $pipes); 34 | return ($this->procClose)($process); 35 | } 36 | 37 | /** 38 | * Specify a callback for opening a new process. 39 | * 40 | * For testing purposes only. 41 | * 42 | * @internal 43 | * 44 | * @var callable 45 | */ 46 | public $procOpen = 'proc_open'; 47 | 48 | /** 49 | * Specify a callback for closing an open process. 50 | * 51 | * For testing purposes only. 52 | * 53 | * @internal 54 | * 55 | * @var callable 56 | */ 57 | public $procClose = 'proc_close'; 58 | } 59 | -------------------------------------------------------------------------------- /src/Common/EditorAwareEventInterface.php: -------------------------------------------------------------------------------- 1 | editor; 22 | } 23 | 24 | public function discoverEditor(string $editor): void 25 | { 26 | $this->editor = $editor; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Common/EventInterface.php: -------------------------------------------------------------------------------- 1 | updateChangelog( 17 | $formatter->format($event->changelog()) 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Common/IOInterface.php: -------------------------------------------------------------------------------- 1 | input; 33 | } 34 | 35 | public function output(): OutputInterface 36 | { 37 | return $this->output; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Common/IsChangelogReadableListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile(); 18 | 19 | if (! is_readable($changelogFile)) { 20 | $event->changelogFileIsUnreadable($changelogFile); 21 | return; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Common/ParseChangelogListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile(); 20 | $parser = new ChangelogParser(); 21 | try { 22 | $changelog = $parser->findChangelogForVersion( 23 | file_get_contents($changelogFile), 24 | $event->version() 25 | ); 26 | } catch (Exception\ExceptionInterface $e) { 27 | $event->errorParsingChangelog($changelogFile, $e); 28 | return; 29 | } 30 | 31 | $event->updateChangelog($changelog); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Common/ValidateVersionListener.php: -------------------------------------------------------------------------------- 1 | version() ?: ''; 21 | $pattern = sprintf('/^(\d+\.\d+\.\d+(%s)?|unreleased)$/i', self::PRE_RELEASE_REGEX); 22 | if (! preg_match($pattern, $version)) { 23 | $event->versionIsInvalid($version); 24 | return; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Common/VersionAwareEventInterface.php: -------------------------------------------------------------------------------- 1 | version; 27 | } 28 | 29 | public function versionIsInvalid(string $version): void 30 | { 31 | $this->failed = true; 32 | $this->output()->writeln(sprintf( 33 | 'Invalid version "%s"; must follow semantic versioning rules', 34 | $version 35 | )); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | changelogFile = realpath(getcwd()) . '/CHANGELOG.md'; 37 | 38 | $githubSpec = new Provider\ProviderSpec('github'); 39 | $githubSpec->setClassName(Provider\GitHub::class); 40 | 41 | $gitlabSpec = new Provider\ProviderSpec('gitlab'); 42 | $gitlabSpec->setClassName(Provider\GitLab::class); 43 | 44 | $this->providers = new Provider\ProviderList(); 45 | $this->providers->add($githubSpec); 46 | $this->providers->add($gitlabSpec); 47 | } 48 | 49 | public function changelogFile(): string 50 | { 51 | return $this->changelogFile; 52 | } 53 | 54 | public function package(): ?string 55 | { 56 | return $this->package; 57 | } 58 | 59 | public function provider(): Provider\ProviderSpec 60 | { 61 | if (! $this->provider) { 62 | return $this->providers->get($this->providerName); 63 | } 64 | 65 | return $this->provider; 66 | } 67 | 68 | public function providers(): Provider\ProviderList 69 | { 70 | return $this->providers; 71 | } 72 | 73 | public function remote(): ?string 74 | { 75 | return $this->remote; 76 | } 77 | 78 | public function setChangelogFile(string $file): void 79 | { 80 | $this->changelogFile = $file; 81 | } 82 | 83 | public function setPackage(string $package): void 84 | { 85 | $this->package = $package; 86 | $this->provider()->setPackage($package); 87 | } 88 | 89 | public function setProviderName(string $providerName): void 90 | { 91 | $this->providerName = $providerName; 92 | $this->provider = $this->providers->get($providerName); 93 | if ($this->package) { 94 | $this->provider->setPackage($this->package); 95 | } 96 | } 97 | 98 | public function setRemote(string $remote): void 99 | { 100 | $this->remote = $remote; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Config/AbstractDiscoverPackageFromFileListener.php: -------------------------------------------------------------------------------- 1 | packageWasFound()) { 34 | // Already discovered 35 | return; 36 | } 37 | 38 | $packageFile = $this->getFileName(); 39 | if (! is_readable($packageFile)) { 40 | // No package file present nothing to do. 41 | return; 42 | } 43 | 44 | $package = json_decode(file_get_contents($packageFile)); 45 | if (! isset($package->name)) { 46 | return; 47 | } 48 | 49 | $event->foundPackage($package->name); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Config/CommonConfigOptionsTrait.php: -------------------------------------------------------------------------------- 1 | addOption( 25 | 'package', 26 | 'p', 27 | InputOption::VALUE_REQUIRED, 28 | 'Package to release; must be in valid for the provider you use.' 29 | ); 30 | } 31 | 32 | private function injectRemoteOption(Command $command): void 33 | { 34 | $command->addOption( 35 | 'remote', 36 | 'r', 37 | InputOption::VALUE_REQUIRED, 38 | 'Git remote to push tag to; defaults to first Git remote matching provider and package' 39 | ); 40 | } 41 | 42 | private function injectProviderOptions(Command $command): void 43 | { 44 | $command->addOption( 45 | 'provider', 46 | null, 47 | InputOption::VALUE_REQUIRED, 48 | 'Named repository provider, based on configuration file definitions;' 49 | . ' "github" and "gitlab" are always available; default is "github"' 50 | ); 51 | 52 | $command->addOption( 53 | 'provider-class', 54 | null, 55 | InputOption::VALUE_REQUIRED, 56 | sprintf( 57 | 'Name of a resolvable PHP class that implements %s for use as your' 58 | . ' repository provider; overrides the --provider option when used', 59 | ProviderInterface::class 60 | ) 61 | ); 62 | 63 | $command->addOption( 64 | 'provider-url', 65 | null, 66 | InputOption::VALUE_REQUIRED, 67 | 'Custom base URL for use with your selected repository provider;' 68 | . ' primarily applies to enterprise github or self-hosted gitlab' 69 | ); 70 | 71 | $command->addOption( 72 | 'provider-token', 73 | null, 74 | InputOption::VALUE_REQUIRED, 75 | 'Personal access/OAuth2 token to use for authenticating with your provider;' 76 | . ' this value is REQUIRED in order to create releases.' 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Config/ConfigDiscovery.php: -------------------------------------------------------------------------------- 1 | input = $input; 27 | $this->output = $output; 28 | $this->config = new Config(); 29 | } 30 | 31 | public function config(): Config 32 | { 33 | return $this->config; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Config/DiscoverPackageFromComposerListener.php: -------------------------------------------------------------------------------- 1 | packageDir ?: realpath(getcwd()); 19 | return $path . '/composer.json'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Config/DiscoverPackageFromNpmPackageListener.php: -------------------------------------------------------------------------------- 1 | packageDir ?: realpath(getcwd()); 19 | return $path . '/package.json'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Config/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | configRoot) { 35 | return $this->configRoot; 36 | } 37 | 38 | $configRoot = getenv('XDG_CONFIG_HOME'); 39 | if ($configRoot) { 40 | return $this->normalizePath($configRoot); 41 | } 42 | 43 | $configRoot = getenv('HOME'); 44 | if (! $configRoot) { 45 | throw new RuntimeException( 46 | 'keep-a-changelog requires either the XDG_CONFIG_HOME or HOME' 47 | . ' env variables be set in order to operate.' 48 | ); 49 | } 50 | 51 | return sprintf('%s/.config', $this->normalizePath($configRoot)); 52 | } 53 | 54 | private function normalizePath(string $path): string 55 | { 56 | return rtrim(strtr($path, '\\', '/'), '/'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Config/PackageNameDiscovery.php: -------------------------------------------------------------------------------- 1 | input = $input; 31 | $this->output = $output; 32 | $this->config = $config; 33 | } 34 | 35 | public function isPropagationStopped(): bool 36 | { 37 | return $this->packageFound 38 | || null !== $this->config->package(); 39 | } 40 | 41 | public function config(): Config 42 | { 43 | return $this->config; 44 | } 45 | 46 | public function packageWasFound(): bool 47 | { 48 | return $this->isPropagationStopped(); 49 | } 50 | 51 | public function foundPackage(string $package): void 52 | { 53 | $this->config->setPackage($package); 54 | $this->packageFound = true; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Config/PromptForGitRemoteListener.php: -------------------------------------------------------------------------------- 1 | remoteWasFound()) { 20 | return; 21 | } 22 | 23 | $choices = array_merge($event->remotes(), ['abort' => 'Abort release']); 24 | 25 | $helper = $event->questionHelper(); 26 | $question = new ChoiceQuestion( 27 | 'More than one valid remote was found; which one should I use?', 28 | $choices 29 | ); 30 | 31 | $remote = $helper->ask($event->input(), $event->output(), $question); 32 | 33 | if ('abort' === $remote) { 34 | $event->abort(); 35 | return; 36 | } 37 | 38 | $event->foundRemote($choices[$remote]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Config/RetrieveGlobalConfigListener.php: -------------------------------------------------------------------------------- 1 | 19 | * [defaults] 20 | * changelog_file = changelog.md 21 | * provider = custom 22 | * remote = upstream 23 | * 24 | * [providers] 25 | * github[class] = Phly\KeepAChangelog\Provider\GitHub 26 | * github[url] = https://github.mwop.net 27 | * github[token] = this-is-a-token 28 | * custom[class] = Mwop\Git\Provider 29 | * custom[url] = https://git.mwop.net 30 | * custom[token] = this-is-a-token 31 | * gitlab[class] = Phly\KeepAChangelog\Provider\GitHub 32 | * gitlab[token] = this-is-a-token 33 | * 34 | */ 35 | class RetrieveGlobalConfigListener extends AbstractConfigListener 36 | { 37 | use LocateGlobalConfigTrait; 38 | 39 | protected function getConfigFile(): string 40 | { 41 | return sprintf('%s/keep-a-changelog.ini', $this->getConfigRoot()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Config/RetrieveLocalConfigListener.php: -------------------------------------------------------------------------------- 1 | 22 | * [defaults] 23 | * ; Note: the following item is the only one that differs from global config 24 | * package = some/package 25 | * changelog_file = changelog.md 26 | * provider = custom 27 | * remote = upstream 28 | * 29 | * [providers] 30 | * github[class] = Phly\KeepAChangelog\Provider\GitHub 31 | * github[url] = https://github.mwop.net 32 | * github[token] = this-is-a-token 33 | * custom[class] = Mwop\Git\Provider 34 | * custom[url] = https://git.mwop.net 35 | * custom[token] = this-is-a-token 36 | * gitlab[class] = Phly\KeepAChangelog\Provider\GitHub 37 | * gitlab[token] = this-is-a-token 38 | * 39 | */ 40 | class RetrieveLocalConfigListener extends AbstractConfigListener 41 | { 42 | /** @var string */ 43 | protected $configType = 'local config file'; 44 | /** @var bool */ 45 | protected $consumeProviderTokens = false; 46 | 47 | protected function getConfigFile(): string 48 | { 49 | return realpath(getcwd()) . '/.keep-a-changelog.ini'; 50 | } 51 | 52 | protected function processDefaults(array $defaults, Config $config): void 53 | { 54 | parent::processDefaults($defaults, $config); 55 | 56 | if (! isset($defaults['package'])) { 57 | return; 58 | } 59 | 60 | $config->setPackage($defaults['package']); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ConfigCommand/AbstractCreateConfigListener.php: -------------------------------------------------------------------------------- 1 | configCreateRequested($event)) { 35 | return; 36 | } 37 | 38 | $configFile = $this->getConfigFileName(); 39 | 40 | if (file_exists($configFile)) { 41 | $event->fileExists($configFile); 42 | return; 43 | } 44 | 45 | $success = file_put_contents($configFile, sprintf( 46 | $this->getConfigTemplate(), 47 | $event->customChangelog() ?: 'CHANGELOG.md' 48 | )); 49 | 50 | if (false === $success) { 51 | $event->creationFailed($configFile); 52 | return; 53 | } 54 | 55 | $event->createdConfigFile($configFile); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ConfigCommand/AbstractEditConfigListener.php: -------------------------------------------------------------------------------- 1 | configEditRequested($event)) { 26 | return; 27 | } 28 | 29 | $configFile = $this->getConfigFile(); 30 | 31 | if (! file_exists($configFile)) { 32 | $event->configFileNotFound($configFile); 33 | return; 34 | } 35 | 36 | $status = $this->getEditor()->spawnEditor( 37 | $event->output(), 38 | $event->editor(), 39 | $configFile 40 | ); 41 | 42 | if (0 !== $status) { 43 | $event->editFailed($configFile); 44 | return; 45 | } 46 | 47 | $event->editComplete($configFile); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ConfigCommand/AbstractRemoveConfigListener.php: -------------------------------------------------------------------------------- 1 | configRemovalRequested($event)) { 26 | return; 27 | } 28 | 29 | $configFile = $this->getConfigFile(); 30 | 31 | if (! file_exists($configFile)) { 32 | $event->configFileNotFound($configFile); 33 | return; 34 | } 35 | 36 | $output = $event->output(); 37 | 38 | $output->writeln(sprintf( 39 | 'Found the following configuration file: %s', 40 | $configFile 41 | )); 42 | 43 | $question = new ConfirmationQuestion('Do you really want to delete this file? ', false); 44 | 45 | if (! $this->getQuestionHelper()->ask($event->input(), $output, $question)) { 46 | $event->abort($configFile); 47 | return; 48 | } 49 | 50 | if (false === ($this->unlink)($configFile)) { 51 | $event->errorRemovingConfig($configFile); 52 | return; 53 | } 54 | 55 | $event->deletedConfigFile($configFile); 56 | } 57 | 58 | public function getQuestionHelper(): QuestionHelper 59 | { 60 | if ($this->questionHelper instanceof QuestionHelper) { 61 | return $this->questionHelper; 62 | } 63 | return new QuestionHelper(); 64 | } 65 | 66 | /** 67 | * Provide a QuestionHelper instance for use in prompting the user for 68 | * confirmation. 69 | * 70 | * For testing purposes only. 71 | * 72 | * @internal 73 | * 74 | * @var null|QuestionHelper 75 | */ 76 | public $questionHelper; 77 | 78 | /** 79 | * Provide a callable for removing a configuration file. 80 | * 81 | * For testing purposes only. 82 | * 83 | * @internal 84 | * 85 | * @var callable 86 | */ 87 | public $unlink = 'unlink'; 88 | } 89 | -------------------------------------------------------------------------------- /src/ConfigCommand/AbstractShowConfigListener.php: -------------------------------------------------------------------------------- 1 | shouldShowConfig($event)) { 26 | return; 27 | } 28 | 29 | $configFile = $this->getConfigFile(); 30 | if (! is_readable($configFile)) { 31 | $event->configIsNotReadable($configFile, $this->getConfigType()); 32 | return; 33 | } 34 | 35 | $this->displayConfig($event, $configFile); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ConfigCommand/CreateConfigEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 45 | $this->output = $output; 46 | $this->createLocal = $createLocal; 47 | $this->createGlobal = $createGlobal; 48 | $this->customChangelog = $customChangelog; 49 | } 50 | 51 | public function isPropagationStopped(): bool 52 | { 53 | return $this->failed; 54 | } 55 | 56 | public function failed(): bool 57 | { 58 | return $this->failed; 59 | } 60 | 61 | public function createGlobal(): bool 62 | { 63 | return $this->createGlobal; 64 | } 65 | 66 | public function createLocal(): bool 67 | { 68 | return $this->createLocal; 69 | } 70 | 71 | public function customChangelog(): ?string 72 | { 73 | return $this->customChangelog; 74 | } 75 | 76 | public function fileExists(string $filename): void 77 | { 78 | $this->output->writeln(sprintf( 79 | 'Config file already exists at %s; skipping', 80 | $filename 81 | )); 82 | } 83 | 84 | public function createdConfigFile(string $configFile): void 85 | { 86 | $this->output->writeln(sprintf('Created %s', $configFile)); 87 | } 88 | 89 | public function creationFailed(string $filename): void 90 | { 91 | $this->failed = true; 92 | $this->output->writeln(sprintf('Failed creating config file %s', $filename)); 93 | $this->output->writeln('Verify you have permission to create the file, and try again.'); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/ConfigCommand/CreateGlobalConfigListener.php: -------------------------------------------------------------------------------- 1 | createGlobal(); 36 | } 37 | 38 | public function getConfigFileName(): string 39 | { 40 | return sprintf('%s/keep-a-changelog.ini', $this->getConfigRoot()); 41 | } 42 | 43 | public function getConfigTemplate(): string 44 | { 45 | return self::TEMPLATE; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ConfigCommand/CreateLocalConfigListener.php: -------------------------------------------------------------------------------- 1 | createLocal(); 20 | } 21 | 22 | public function getConfigFileName(): string 23 | { 24 | return sprintf('%s/.keep-a-changelog.ini', $this->configRoot ?: getcwd()); 25 | } 26 | 27 | public function getConfigTemplate(): string 28 | { 29 | // Done this way due to issues with PHAR creation 30 | return implode("\n", [ 31 | '[defaults]', 32 | 'changelog_file = %s', 33 | 'provider = github', 34 | 'remote = origin', 35 | '', 36 | '[providers]', 37 | 'github[class] = Phly\KeepAChangelog\Provider\GitHub', 38 | 'gitlab[class] = Phly\KeepAChangelog\Provider\GitLab', 39 | '', 40 | ]); 41 | } 42 | 43 | /** 44 | * Set a specific directory in which to look for the local config file. 45 | * 46 | * For testing purposes only. 47 | * 48 | * @internal 49 | * 50 | * @var null|string 51 | */ 52 | public $configRoot; 53 | } 54 | -------------------------------------------------------------------------------- /src/ConfigCommand/EditCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 38 | parent::__construct($name); 39 | } 40 | 41 | protected function configure() 42 | { 43 | $this->setDescription(self::DESCRIPTION); 44 | $this->setHelp(self::HELP); 45 | $this->addOption( 46 | 'global', 47 | 'g', 48 | InputOption::VALUE_NONE, 49 | 'Edit the global configuration file ($XDG_CONFIG_HOME/keep-a-changelog.ini)' 50 | ); 51 | $this->addOption( 52 | 'local', 53 | 'l', 54 | InputOption::VALUE_NONE, 55 | 'Edit the local configuration file (./.keep-a-changelog.ini)' 56 | ); 57 | 58 | $this->injectEditorOption($this); 59 | } 60 | 61 | protected function execute(InputInterface $input, OutputInterface $output): int 62 | { 63 | return $this->dispatcher 64 | ->dispatch(new EditConfigEvent( 65 | $input, 66 | $output, 67 | $input->getOption('local') ?: false, 68 | $input->getOption('global') ?: false, 69 | $input->getOption('editor') ?: null 70 | )) 71 | ->failed() 72 | ? 1 73 | : 0; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ConfigCommand/EditGlobalConfigListener.php: -------------------------------------------------------------------------------- 1 | editGlobal(); 22 | } 23 | 24 | public function getConfigFile(): string 25 | { 26 | return sprintf('%s/keep-a-changelog.ini', $this->getConfigRoot()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ConfigCommand/EditLocalConfigListener.php: -------------------------------------------------------------------------------- 1 | editLocal(); 19 | } 20 | 21 | public function getConfigFile(): string 22 | { 23 | return sprintf('%s/.keep-a-changelog.ini', $this->configRoot ?: getcwd()); 24 | } 25 | 26 | /** 27 | * Set a specific directory in which to look for the local config file. 28 | * 29 | * For testing purposes only. 30 | * 31 | * @internal 32 | * 33 | * @var null|string 34 | */ 35 | public $configRoot; 36 | } 37 | -------------------------------------------------------------------------------- /src/ConfigCommand/MaskProviderTokensTrait.php: -------------------------------------------------------------------------------- 1 | $data) { 20 | $config['providers'][$name]['token'] = isset($data['token']) ? 'Yes' : 'No'; 21 | } 22 | 23 | return $config; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ConfigCommand/RemoveCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 34 | parent::__construct($name); 35 | } 36 | 37 | protected function configure() 38 | { 39 | $this->setDescription(self::DESCRIPTION); 40 | $this->setHelp(self::HELP); 41 | $this->addOption( 42 | 'global', 43 | 'g', 44 | InputOption::VALUE_NONE, 45 | 'Edit the global configuration file ($XDG_CONFIG_HOME/keep-a-changelog.ini)' 46 | ); 47 | $this->addOption( 48 | 'local', 49 | 'l', 50 | InputOption::VALUE_NONE, 51 | 'Edit the local configuration file (./.keep-a-changelog.ini)' 52 | ); 53 | } 54 | 55 | protected function execute(InputInterface $input, OutputInterface $output): int 56 | { 57 | return $this->dispatcher 58 | ->dispatch(new RemoveConfigEvent( 59 | $input, 60 | $output, 61 | $input->getOption('local') ?: false, 62 | $input->getOption('global') ?: false 63 | )) 64 | ->failed() 65 | ? 1 66 | : 0; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/ConfigCommand/RemoveGlobalConfigListener.php: -------------------------------------------------------------------------------- 1 | removeGlobal(); 22 | } 23 | 24 | public function getConfigFile(): string 25 | { 26 | return sprintf('%s/keep-a-changelog.ini', $this->getConfigRoot()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ConfigCommand/RemoveLocalConfigListener.php: -------------------------------------------------------------------------------- 1 | removeLocal(); 19 | } 20 | 21 | public function getConfigFile(): string 22 | { 23 | return sprintf('%s/.keep-a-changelog.ini', $this->configRoot ?: getcwd()); 24 | } 25 | 26 | /** 27 | * Set a specific directory in which to look for the local config file. 28 | * 29 | * For testing purposes only. 30 | * 31 | * @internal 32 | * 33 | * @var null|string 34 | */ 35 | public $configRoot; 36 | } 37 | -------------------------------------------------------------------------------- /src/ConfigCommand/ShowCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 37 | parent::__construct($name); 38 | } 39 | 40 | protected function configure() 41 | { 42 | $this->setDescription(self::DESCRIPTION); 43 | $this->setHelp(self::HELP); 44 | $this->addOption( 45 | 'global', 46 | 'g', 47 | InputOption::VALUE_NONE, 48 | 'Show configuration from the global configuration file ($XDG_CONFIG_HOME/keep-a-changelog.ini)' 49 | ); 50 | $this->addOption( 51 | 'local', 52 | 'l', 53 | InputOption::VALUE_NONE, 54 | 'Show configuration from the local configuration file (./.keep-a-changelog.ini)' 55 | ); 56 | } 57 | 58 | protected function execute(InputInterface $input, OutputInterface $output): int 59 | { 60 | return $this->dispatcher 61 | ->dispatch(new ShowConfigEvent( 62 | $input, 63 | $output, 64 | $input->getOption('local') ?: false, 65 | $input->getOption('global') ?: false 66 | )) 67 | ->failed() 68 | ? 1 69 | : 0; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ConfigCommand/ShowConfigEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 38 | $this->output = $output; 39 | $this->showLocal = $showLocal; 40 | $this->showGlobal = $showGlobal; 41 | $this->showMerged = ($showLocal && $showGlobal) || ! ($showGlobal || $showLocal); 42 | } 43 | 44 | public function isPropagationStopped(): bool 45 | { 46 | return $this->finished || $this->failed; 47 | } 48 | 49 | public function showGlobal(): bool 50 | { 51 | return $this->showGlobal; 52 | } 53 | 54 | public function showLocal(): bool 55 | { 56 | return $this->showLocal; 57 | } 58 | 59 | public function showMerged(): bool 60 | { 61 | return $this->showMerged; 62 | } 63 | 64 | public function displayConfig(string $config, string $type, string $location): void 65 | { 66 | $this->finished = true; 67 | $this->output->writeln(sprintf( 68 | 'Showing %s configuration (%s)', 69 | $type, 70 | $location 71 | )); 72 | $this->output->writeln($config); 73 | $this->output->writeln(''); 74 | } 75 | 76 | public function displayMergedConfig(string $config): void 77 | { 78 | $this->finished = true; 79 | $this->output->writeln('Showing merged configuration'); 80 | $this->output->writeln($config); 81 | $this->output->writeln(''); 82 | } 83 | 84 | public function configIsNotReadable(string $configFile, string $type): void 85 | { 86 | $this->failed = true; 87 | $this->output->writeln('Unable to read configuration'); 88 | $this->output->writeln(sprintf( 89 | 'The %s configuration file "%s" either does not exist, or is not readable.', 90 | $type, 91 | $configFile 92 | )); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/ConfigCommand/ShowGlobalConfigListener.php: -------------------------------------------------------------------------------- 1 | showGlobal() && ! $event->showMerged(); 25 | } 26 | 27 | public function getConfigFile(): string 28 | { 29 | return sprintf('%s/keep-a-changelog.ini', $this->getConfigRoot()); 30 | } 31 | 32 | public function getConfigType(): string 33 | { 34 | return 'global'; 35 | } 36 | 37 | public function displayConfig(ShowConfigEvent $event, string $configFile): void 38 | { 39 | $event->displayConfig( 40 | $this->filterConfiguration($configFile), 41 | 'global', 42 | $configFile 43 | ); 44 | } 45 | 46 | private function filterConfiguration(string $configFile): string 47 | { 48 | $config = $this->readIniFile($configFile); 49 | return $this->arrayToIniString($this->maskProviderTokens($config)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ConfigCommand/ShowLocalConfigListener.php: -------------------------------------------------------------------------------- 1 | showLocal() && ! $event->showMerged(); 20 | } 21 | 22 | public function getConfigFile(): string 23 | { 24 | return sprintf('%s/.keep-a-changelog.ini', $this->configRoot ?: getcwd()); 25 | } 26 | 27 | public function getConfigType(): string 28 | { 29 | return 'local'; 30 | } 31 | 32 | public function displayConfig(ShowConfigEvent $event, string $configFile): void 33 | { 34 | $event->displayConfig( 35 | file_get_contents($configFile), 36 | 'local', 37 | $configFile 38 | ); 39 | } 40 | 41 | /** 42 | * Set a specific directory in which to look for the local config file. 43 | * 44 | * For testing purposes only. 45 | * 46 | * @internal 47 | * 48 | * @var null|string 49 | */ 50 | public $configRoot; 51 | } 52 | -------------------------------------------------------------------------------- /src/ConfigCommand/ShowMergedConfigListener.php: -------------------------------------------------------------------------------- 1 | showMerged()) { 29 | return; 30 | } 31 | 32 | $globalConfigFile = sprintf('%s/keep-a-changelog.ini', $this->getConfigRoot()); 33 | if (! is_readable($globalConfigFile)) { 34 | $event->configIsNotReadable($globalConfigFile, 'global'); 35 | return; 36 | } 37 | 38 | $localConfigFile = sprintf('%s/.keep-a-changelog.ini', $this->localConfigRoot ?: getcwd()); 39 | if (! is_readable($localConfigFile)) { 40 | $event->configIsNotReadable($localConfigFile, 'local'); 41 | return; 42 | } 43 | 44 | $config = $this->arrayMergeRecursive( 45 | $this->readIniFile($globalConfigFile), 46 | $this->readIniFile($localConfigFile) 47 | ); 48 | 49 | $event->displayMergedConfig( 50 | $this->arrayToIniString($this->maskProviderTokens($config)) 51 | ); 52 | } 53 | 54 | /** 55 | * Set a specific directory in which to look for the local config file. 56 | * 57 | * For testing purposes only. 58 | * 59 | * @internal 60 | * 61 | * @var null|string 62 | */ 63 | public $localConfigRoot; 64 | } 65 | -------------------------------------------------------------------------------- /src/ConfigCommand/VerifyEditOptionsListener.php: -------------------------------------------------------------------------------- 1 | editLocal() && $event->editGlobal()) { 16 | $event->tooManyOptions(); 17 | return; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ConfigCommand/VerifyRemoveOptionsListener.php: -------------------------------------------------------------------------------- 1 | removeLocal() && ! $event->removeGlobal()) { 16 | $event->missingOptions(); 17 | return; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Entry/ConfigListener.php: -------------------------------------------------------------------------------- 1 | input()->getOption('pr') 20 | || $event->input()->getOption('issue') 21 | ) { 22 | $this->requiresPackageName = true; 23 | } 24 | parent::__invoke($event); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Entry/EntryTypes.php: -------------------------------------------------------------------------------- 1 | index; 43 | case 'type': 44 | return $this->type; 45 | default: 46 | throw new UnexpectedValueException(sprintf( 47 | 'The property "%s" does not exist for class "%s"', 48 | $name, 49 | self::class 50 | )); 51 | } 52 | } 53 | 54 | /** 55 | * @param mixed $value 56 | */ 57 | public function __set(string $name, $value) 58 | { 59 | switch ($name) { 60 | case 'index': 61 | $this->setIndex($value); 62 | break; 63 | case 'type': 64 | $this->setType($value); 65 | break; 66 | default: 67 | throw new UnexpectedValueException(sprintf( 68 | 'The property "%s" does not exist for class "%s"', 69 | $name, 70 | self::class 71 | )); 72 | } 73 | } 74 | 75 | private function setIndex(int $value): void 76 | { 77 | $this->index = $value; 78 | } 79 | 80 | private function setType(string $value): void 81 | { 82 | if (! in_array($value, self::ACTIONS, true)) { 83 | throw new TypeError( 84 | 'Property type expects one of the constants ACTION_INJECT, ACTION_REPLACE, or ACTION_NOT_FOUND' 85 | ); 86 | } 87 | $this->type = $value; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Entry/IsEntryArgumentEmptyListener.php: -------------------------------------------------------------------------------- 1 | entry()) { 16 | $event->entryIsEmpty(); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Entry/NotifyPreparingEntryListener.php: -------------------------------------------------------------------------------- 1 | output()->writeln(sprintf( 19 | 'Preparing entry for %s section', 20 | ucwords($event->entryType()) 21 | )); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Entry/PrependIssueLinkListener.php: -------------------------------------------------------------------------------- 1 | issueNumber(); 18 | } 19 | 20 | public function generateLink(ProviderInterface $provider, int $identifier): string 21 | { 22 | return $provider->generateIssueLink($identifier); 23 | } 24 | 25 | public function reportInvalidIdentifier(AddChangelogEntryEvent $event, int $identifier): void 26 | { 27 | $event->issueNumberIsInvalid($identifier); 28 | } 29 | 30 | public function reportInvalidLink(AddChangelogEntryEvent $event, string $link): void 31 | { 32 | $event->issueLinkIsInvalid($link); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Entry/PrependPatchLinkListener.php: -------------------------------------------------------------------------------- 1 | patchNumber(); 18 | } 19 | 20 | public function generateLink(ProviderInterface $provider, int $identifier): string 21 | { 22 | return $provider->generatePatchLink($identifier); 23 | } 24 | 25 | public function reportInvalidIdentifier(AddChangelogEntryEvent $event, int $identifier): void 26 | { 27 | $event->patchNumberIsInvalid($identifier); 28 | } 29 | 30 | public function reportInvalidLink(AddChangelogEntryEvent $event, string $link): void 31 | { 32 | $event->patchLinkIsInvalid($link); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/EventDispatcher.php: -------------------------------------------------------------------------------- 1 | listenerProvider = $listenerProvider; 26 | } 27 | 28 | public function dispatch(object $event): object 29 | { 30 | $stoppable = $event instanceof StoppableEventInterface; 31 | 32 | if ($stoppable && $event->isPropagationStopped()) { 33 | return $event; 34 | } 35 | 36 | foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) { 37 | $listener($event); 38 | 39 | if ($stoppable && $event->isPropagationStopped()) { 40 | break; 41 | } 42 | } 43 | 44 | return $event; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Exception/ChangelogEntriesNotFoundException.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 39 | parent::__construct($name); 40 | } 41 | 42 | protected function configure(): void 43 | { 44 | $this->setDescription('Close a milestone for this package via your provider'); 45 | $this->setHelp(self::HELP); 46 | 47 | $this->addArgument('id', InputArgument::REQUIRED, 'Milestone ID'); 48 | 49 | $this->injectPackageOption($this); 50 | $this->injectRemoteOption($this); 51 | $this->injectProviderOptions($this); 52 | } 53 | 54 | protected function execute(InputInterface $input, OutputInterface $output): int 55 | { 56 | return $this->dispatcher 57 | ->dispatch(new CloseMilestoneEvent( 58 | $input, 59 | $output, 60 | $this->dispatcher 61 | )) 62 | ->failed() 63 | ? 1 64 | : 0; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Milestone/CloseMilestoneEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 29 | $this->output = $output; 30 | $this->dispatcher = $dispatcher; 31 | $this->id = (int) $input->getArgument('id'); 32 | } 33 | 34 | public function isPropagationStopped(): bool 35 | { 36 | return $this->failed; 37 | } 38 | 39 | public function id(): int 40 | { 41 | return $this->id; 42 | } 43 | 44 | public function milestoneClosed(): void 45 | { 46 | $this->output()->writeln(sprintf( 47 | 'Closed milestone %d', 48 | $this->id() 49 | )); 50 | } 51 | 52 | public function errorClosingMilestone(Throwable $e): void 53 | { 54 | $this->failed = true; 55 | 56 | if ((int) $e->getCode() === 401) { 57 | $this->reportAuthenticationException($e); 58 | return; 59 | } 60 | 61 | $this->reportStandardException($e); 62 | } 63 | 64 | private function reportStandardException(Throwable $e): void 65 | { 66 | $output = $this->output(); 67 | 68 | $output->writeln('Error closing milestone!'); 69 | $output->writeln('An error occurred when attempting to close the milestone:'); 70 | $output->writeln(''); 71 | $output->writeln('Error Message: ' . $e->getMessage()); 72 | } 73 | 74 | private function reportAuthenticationException(Throwable $e): void 75 | { 76 | $output = $this->output(); 77 | 78 | $output->writeln('Invalid credentials'); 79 | $output->writeln('The credentials associated with your Git provider are invalid.'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Milestone/CloseMilestoneListener.php: -------------------------------------------------------------------------------- 1 | id(); 20 | $provider = $event->provider(); 21 | 22 | $event->output()->writeln(sprintf( 23 | 'Closing milestone %d', 24 | $id 25 | )); 26 | 27 | try { 28 | $status = $provider->closeMilestone($id); 29 | } catch (Throwable $e) { 30 | $event->errorClosingMilestone($e); 31 | return; 32 | } 33 | 34 | $event->milestoneClosed(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Milestone/CommandConfigListener.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 37 | parent::__construct($name); 38 | } 39 | 40 | protected function configure(): void 41 | { 42 | $this->setDescription('Create a new milestone for this package via your provider'); 43 | $this->setHelp(self::HELP); 44 | 45 | $this->addArgument('title', InputArgument::REQUIRED, 'Title/name of milestone'); 46 | $this->addArgument('description', InputArgument::OPTIONAL, 'Milestone description'); 47 | 48 | $this->injectPackageOption($this); 49 | $this->injectRemoteOption($this); 50 | $this->injectProviderOptions($this); 51 | } 52 | 53 | protected function execute(InputInterface $input, OutputInterface $output): int 54 | { 55 | return $this->dispatcher 56 | ->dispatch(new CreateMilestoneEvent( 57 | $input, 58 | $output, 59 | $this->dispatcher 60 | )) 61 | ->failed() 62 | ? 1 63 | : 0; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Milestone/CreateMilestoneListener.php: -------------------------------------------------------------------------------- 1 | title(); 22 | $description = $event->description(); 23 | /** @var ProviderInterface|MilestoneAwareProviderInterface $provider */ 24 | $provider = $event->provider(); 25 | 26 | $event->output()->writeln(sprintf( 27 | 'Creating milestone "%s" (%s)', 28 | $title, 29 | $description 30 | )); 31 | 32 | try { 33 | $milestone = $provider->createMilestone($title, $description); 34 | } catch (Throwable $e) { 35 | $event->errorCreatingMilestone($e); 36 | return; 37 | } 38 | 39 | $event->milestoneCreated($milestone); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Milestone/ListCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 35 | parent::__construct($name); 36 | } 37 | 38 | protected function configure(): void 39 | { 40 | $this->setDescription('List milestones for this package via your provider'); 41 | $this->setHelp(self::HELP); 42 | 43 | $this->injectPackageOption($this); 44 | $this->injectRemoteOption($this); 45 | $this->injectProviderOptions($this); 46 | } 47 | 48 | protected function execute(InputInterface $input, OutputInterface $output): int 49 | { 50 | return $this->dispatcher 51 | ->dispatch(new ListMilestonesEvent( 52 | $input, 53 | $output, 54 | $this->dispatcher 55 | )) 56 | ->failed() 57 | ? 1 58 | : 0; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Milestone/ListMilestonesListener.php: -------------------------------------------------------------------------------- 1 | provider(); 21 | 22 | $event->output()->writeln('Fetching milestones...'); 23 | 24 | try { 25 | $milestones = $provider->listMilestones(); 26 | } catch (Throwable $e) { 27 | $event->errorListingMilestones($e); 28 | return; 29 | } 30 | 31 | $event->milestonesRetrieved($milestones); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Milestone/VerifyProviderListener.php: -------------------------------------------------------------------------------- 1 | config(); 18 | $providerSpec = $config->provider(); 19 | 20 | if (! $providerSpec->isComplete()) { 21 | $event->providerIsIncomplete(); 22 | return; 23 | } 24 | 25 | $provider = $providerSpec->createProvider(); 26 | 27 | if (! $provider instanceof MilestoneAwareProviderInterface) { 28 | $event->providerIncapableOfMilestones(); 29 | return; 30 | } 31 | 32 | $event->discoveredProvider($provider); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Provider/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | getCode(), $e); 26 | } 27 | 28 | public static function forTagOnGithub(string $package, string $version, Throwable $e): self 29 | { 30 | return new self(sprintf( 31 | 'When verifying that the tag %s for package %s is present on GitHub,' 32 | . ' an error occurred fetching tag details: %s', 33 | $version, 34 | $package, 35 | $e->getMessage() 36 | ), $e->getCode(), $e); 37 | } 38 | 39 | public static function forUnverifiedTagOnGithub(string $package, string $version): self 40 | { 41 | return new self(sprintf( 42 | 'When verifying that the tag %s for package %s is present on GitHub,' 43 | . ' the tag found was unsigned. Please recreate the tag using the' 44 | . ' -s flag.', 45 | $version, 46 | $package 47 | )); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Provider/Exception/MissingTokenException.php: -------------------------------------------------------------------------------- 1 | id = $id; 25 | $this->title = $title; 26 | $this->description = $description; 27 | } 28 | 29 | public function id(): int 30 | { 31 | return $this->id; 32 | } 33 | 34 | public function title(): string 35 | { 36 | return $this->title; 37 | } 38 | 39 | public function description(): string 40 | { 41 | return $this->description; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Provider/MilestoneAwareProviderInterface.php: -------------------------------------------------------------------------------- 1 | 43 | * [#17](https://github.com/not-an-org/not-a-repo/issues/17) 44 | * 45 | * 46 | * @throws Exception\MissingPackageNameException 47 | */ 48 | public function generateIssueLink(int $issueIdentifier): string; 49 | 50 | /** 51 | * This method should generate the full markdown link to a patch. 52 | * 53 | * As an example of something it could generate: 54 | * 55 | * 56 | * [#17](https://github.com/not-an-org/not-a-repo/pull/17) 57 | * 58 | * 59 | * @throws Exception\MissingPackageNameException 60 | */ 61 | public function generatePatchLink(int $patchIdentifier): string; 62 | 63 | /** 64 | * Set the package name to use in links and when creating the release name. 65 | */ 66 | public function setPackageName(string $package): void; 67 | 68 | /** 69 | * Set the authentication token to use for API calls to the provider. 70 | */ 71 | public function setToken(string $token): void; 72 | 73 | /** 74 | * Set the base URL to use for API calls to the provider. 75 | * 76 | * Generally, this should only be the scheme + authority. 77 | */ 78 | public function setUrl(string $url): void; 79 | } 80 | -------------------------------------------------------------------------------- /src/Provider/ProviderList.php: -------------------------------------------------------------------------------- 1 | */ 16 | private $providers = []; 17 | 18 | public function has(string $name): bool 19 | { 20 | return isset($this->providers[$name]); 21 | } 22 | 23 | public function get(string $name): ?ProviderSpec 24 | { 25 | return $this->providers[$name] ?? null; 26 | } 27 | 28 | public function add(ProviderSpec $provider): void 29 | { 30 | $this->providers[$provider->name()] = $provider; 31 | } 32 | 33 | public function listKnownTypes(): array 34 | { 35 | return array_keys($this->providers); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Provider/ProviderSpec.php: -------------------------------------------------------------------------------- 1 | name = $name; 39 | } 40 | 41 | public function isComplete(): bool 42 | { 43 | return $this->className 44 | && class_exists($this->className) 45 | && in_array(ProviderInterface::class, class_implements($this->className), true); 46 | } 47 | 48 | public function createProvider(): ProviderInterface 49 | { 50 | $class = $this->className; 51 | $provider = new $class(); 52 | 53 | if ($this->package) { 54 | $provider->setPackageName($this->package); 55 | } 56 | 57 | if ($this->token) { 58 | $provider->setToken($this->token); 59 | } 60 | 61 | if ($this->url) { 62 | $provider->setUrl($this->url); 63 | } 64 | 65 | return $provider; 66 | } 67 | 68 | public function name(): string 69 | { 70 | return $this->name; 71 | } 72 | 73 | public function url(): ?string 74 | { 75 | return $this->url; 76 | } 77 | 78 | public function setClassName(string $className): void 79 | { 80 | $this->className = $className; 81 | } 82 | 83 | public function setPackage(string $package): void 84 | { 85 | $this->package = $package; 86 | } 87 | 88 | public function setToken(string $token): void 89 | { 90 | $this->token = $token; 91 | } 92 | 93 | public function setUrl(string $url): void 94 | { 95 | $this->url = $url; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Unreleased/PromoteEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 38 | $this->output = $output; 39 | $this->dispatcher = $dispatcher; 40 | $this->newVersion = $version; 41 | $this->releaseDate = $releaseDate; 42 | } 43 | 44 | public function isPropagationStopped(): bool 45 | { 46 | return $this->failed; 47 | } 48 | 49 | public function newVersion(): string 50 | { 51 | return $this->newVersion; 52 | } 53 | 54 | public function releaseDate(): string 55 | { 56 | return $this->releaseDate; 57 | } 58 | 59 | public function version(): string 60 | { 61 | return 'unreleased'; 62 | } 63 | 64 | public function versionIsInvalid(string $version): void 65 | { 66 | // intentional no-op; never should get called 67 | } 68 | 69 | public function didNotPromote(): void 70 | { 71 | $this->failed = true; 72 | $this->output()->writeln('Invalid date provided for release; must be in Y-m-d format'); 73 | } 74 | 75 | public function changelogReady(): void 76 | { 77 | $this->output->writeln(sprintf( 78 | 'Renamed Unreleased entry to "%s" with release date of "%s"', 79 | $this->newVersion, 80 | $this->releaseDate 81 | )); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Unreleased/PromoteUnreleasedToNewVersionListener.php: -------------------------------------------------------------------------------- 1 | changelogEntry(); 24 | $lines = explode("\n", $entry->contents); 25 | $lines[0] = sprintf('## %s - %s', $event->newVersion(), $event->releaseDate()); 26 | 27 | $this->getChangelogEditor()->update( 28 | $event->config()->changelogFile(), 29 | implode("\n", $lines), 30 | $entry 31 | ); 32 | 33 | $event->changelogReady(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Unreleased/ValidateDateToUseListener.php: -------------------------------------------------------------------------------- 1 | releaseDate())) { 18 | return; 19 | } 20 | 21 | $event->didNotPromote(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Version/CheckTreeForChangesListener.php: -------------------------------------------------------------------------------- 1 | 21 | * function(string $command[, array &$output[, int &$return]]) 22 | * 23 | * 24 | * @var callable 25 | */ 26 | public $exec = 'exec'; 27 | 28 | public function __invoke(TagReleaseEvent $event): void 29 | { 30 | if ($event->input()->getOption('force')) { 31 | return; 32 | } 33 | 34 | $command = 'git status -s'; 35 | $output = []; 36 | $status = 0; 37 | $exec = $this->exec; 38 | 39 | $exec($command, $output, $status); 40 | 41 | if ($status !== 0 || count($output) > 0) { 42 | $event->unversionedChangesPresent(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Version/CreateReleaseNameListener.php: -------------------------------------------------------------------------------- 1 | input()->getOption('name'); 20 | if ($name) { 21 | $event->setReleaseName($name); 22 | return; 23 | } 24 | 25 | $package = $event->config()->package(); 26 | $version = $event->version(); 27 | $lastSeparator = strrpos($package, '/'); 28 | $repo = substr($package, $lastSeparator + 1); 29 | $event->setReleaseName(sprintf('%s %s', $repo, $version)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Version/DiscoverVersionEventTrait.php: -------------------------------------------------------------------------------- 1 | version = $version; 16 | $this->tagName = $this->tagName ?: $version; 17 | } 18 | 19 | public function versionNotAccepted(): void 20 | { 21 | $this->failed = true; 22 | $this->output->writeln('No version specified'); 23 | $this->output->writeln('Please specify a version via the argument.'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Version/DiscoverVersionListener.php: -------------------------------------------------------------------------------- 1 | version(); 37 | if (is_string($version) && ! empty($version)) { 38 | // Version was provided already 39 | return; 40 | } 41 | 42 | $readyVersions = $this->getReadyVersions( 43 | (new ChangelogParser()) 44 | ->findAllVersions($event->config()->changelogFile()) 45 | ); 46 | 47 | if (0 === count($readyVersions)) { 48 | // No versions found; let version validator flag it as invalid 49 | return; 50 | } 51 | 52 | $versionToTag = array_shift($readyVersions); 53 | $question = new ConfirmationQuestion(sprintf( 54 | "Most recent version in changelog file is %s; use this version? [Y/n]" . PHP_EOL . "> ", 55 | $versionToTag 56 | )); 57 | 58 | $questionHelper = $this->questionHelper ?: new QuestionHelper(); 59 | if (! $questionHelper->ask($event->input(), $event->output(), $question)) { 60 | $event->versionNotAccepted(); 61 | return; 62 | } 63 | 64 | $event->foundVersion($versionToTag); 65 | } 66 | 67 | private function getReadyVersions(iterable $versions): array 68 | { 69 | $readyVersions = []; 70 | 71 | foreach ($versions as $version => $date) { 72 | if (! preg_match('/^\d{4}-\d{2}\-\d{2}$/', $date)) { 73 | continue; 74 | } 75 | 76 | $readyVersions[] = $version; 77 | } 78 | 79 | return $readyVersions; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Version/DiscoverableVersionEventInterface.php: -------------------------------------------------------------------------------- 1 | input = $input; 39 | $this->output = $output; 40 | $this->dispatcher = $dispatcher; 41 | $this->version = $version; 42 | $this->editor = $editor; 43 | } 44 | 45 | public function isPropagationStopped(): bool 46 | { 47 | return $this->failed; 48 | } 49 | 50 | public function editorFailed(): void 51 | { 52 | $this->failed = true; 53 | $this->output->writeln(sprintf( 54 | 'Could not edit %s; please check the output for details.', 55 | $this->config()->changelogFile() 56 | )); 57 | } 58 | 59 | public function editComplete(): void 60 | { 61 | $message = $this->version 62 | ? sprintf( 63 | 'Edited change for version %s in %s', 64 | $this->version, 65 | $this->config()->changelogFile() 66 | ) 67 | : sprintf( 68 | 'Edited most recent changelog in %s', 69 | $this->config()->changelogFile() 70 | ); 71 | $this->output->writeln($message); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Version/EditChangelogVersionListener.php: -------------------------------------------------------------------------------- 1 | changelogEntry(); 24 | $tempFile = $this->createTempFileWithContents( 25 | $changelogEntry->contents 26 | ); 27 | 28 | $status = $this->getEditor()->spawnEditor( 29 | $event->output(), 30 | $event->editor(), 31 | $tempFile 32 | ); 33 | 34 | if (0 !== $status) { 35 | $this->unlinkTempFile($tempFile); 36 | $event->editorFailed(); 37 | return; 38 | } 39 | 40 | $this->getChangelogEditor()->update( 41 | $event->config()->changelogFile(), 42 | file_get_contents($tempFile), 43 | $changelogEntry 44 | ); 45 | 46 | $this->unlinkTempFile($tempFile); 47 | $event->editComplete(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Version/EditCommand.php: -------------------------------------------------------------------------------- 1 | is provided, assumes the most recent changelog should be used. 29 | 30 | By default, the command will edit CHANGELOG.md in the current directory, unless 31 | a different file is specified via the --file option. 32 | EOH; 33 | 34 | /** @var EventDispatcherInterface */ 35 | private $dispatcher; 36 | 37 | public function __construct(EventDispatcherInterface $dispatcher, ?string $name = null) 38 | { 39 | $this->dispatcher = $dispatcher; 40 | parent::__construct($name); 41 | } 42 | 43 | protected function configure(): void 44 | { 45 | $this->setDescription(self::DESCRIPTION); 46 | $this->setHelp(self::HELP); 47 | 48 | $this->addArgument( 49 | 'version', 50 | InputArgument::OPTIONAL, 51 | 'A specific changelog version to edit; defaults to latest.' 52 | ); 53 | 54 | $this->injectEditorOption($this); 55 | } 56 | 57 | protected function execute(InputInterface $input, OutputInterface $output): int 58 | { 59 | return $this->dispatcher 60 | ->dispatch(new EditChangelogVersionEvent( 61 | $input, 62 | $output, 63 | $this->dispatcher, 64 | $input->getArgument('version') ?: null, 65 | $input->getOption('editor') ?: null 66 | )) 67 | ->failed() 68 | ? 1 69 | : 0; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Version/ListCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 31 | parent::__construct($name); 32 | } 33 | 34 | protected function configure(): void 35 | { 36 | $this->setDescription(self::DESCRIPTION); 37 | $this->setHelp(self::HELP); 38 | } 39 | 40 | protected function execute(InputInterface $input, OutputInterface $output): int 41 | { 42 | return $this->dispatcher 43 | ->dispatch(new ListVersionsEvent($input, $output, $this->dispatcher)) 44 | ->failed() 45 | ? 1 46 | : 0; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Version/ListVersionsEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 24 | $this->output = $output; 25 | $this->dispatcher = $dispatcher; 26 | } 27 | 28 | public function isPropagationStopped(): bool 29 | { 30 | return $this->failed; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Version/ListVersionsListener.php: -------------------------------------------------------------------------------- 1 | output(); 22 | 23 | $output->writeln('Found the following versions:'); 24 | foreach ((new ChangelogParser())->findAllVersions($event->config()->changelogFile()) as $version => $date) { 25 | $output->writeln(sprintf( 26 | '- %s%s(release date: %s)', 27 | $version, 28 | str_repeat("\t", strlen($version) < 8 ? 2 : 1), 29 | $date 30 | )); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Version/PromptForRemovalConfirmationListener.php: -------------------------------------------------------------------------------- 1 | input(); 19 | if ($input->hasOption('force-removal') && $input->getOption('force-removal')) { 20 | // No need to prompt 21 | return; 22 | } 23 | 24 | $entry = $event->changelogEntry(); 25 | $output = $event->output(); 26 | 27 | $output->writeln('Found the following entry:'); 28 | $output->writeln($entry->contents); 29 | 30 | $question = new ConfirmationQuestion('Do you really want to delete this version ([y]es/[n]o)? ', false); 31 | 32 | if (! $this->getQuestionHelper()->ask($input, $output, $question)) { 33 | $event->abort(); 34 | return; 35 | } 36 | } 37 | 38 | private function getQuestionHelper(): QuestionHelper 39 | { 40 | if ($this->questionHelper instanceof QuestionHelper) { 41 | return $this->questionHelper; 42 | } 43 | return new QuestionHelper(); 44 | } 45 | 46 | /** 47 | * Provide an alternative question helper for use in prompting. 48 | * 49 | * For testing purposes only. 50 | * 51 | * @internal 52 | * 53 | * @var null|QuestionHelper 54 | */ 55 | public $questionHelper; 56 | } 57 | -------------------------------------------------------------------------------- /src/Version/PushReleaseToProviderListener.php: -------------------------------------------------------------------------------- 1 | releaseName(); 20 | $provider = $event->provider(); 21 | 22 | $event->output()->writeln(sprintf( 23 | 'Creating release "%s"', 24 | $releaseName 25 | )); 26 | 27 | try { 28 | $release = $provider->createRelease( 29 | $releaseName, 30 | $event->tagName(), 31 | $event->changelog() 32 | ); 33 | } catch (Throwable $e) { 34 | $event->errorCreatingRelease($e); 35 | return; 36 | } 37 | 38 | if (! $release) { 39 | $event->unexpectedProviderResult(); 40 | return; 41 | } 42 | 43 | $event->releaseCreated($release); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Version/PushTagToRemoteListener.php: -------------------------------------------------------------------------------- 1 | 22 | * function (string $command[, array &$output[, int &$exitStatus]]) : void 23 | * 24 | * 25 | * @internal 26 | * 27 | * @var callable 28 | */ 29 | public $exec = 'exec'; 30 | 31 | public function __invoke(ReleaseEvent $event): void 32 | { 33 | $tagName = $event->tagName(); 34 | $remote = $event->config()->remote(); 35 | 36 | $event->output()->writeln(sprintf( 37 | 'Pushing tag %s to %s', 38 | $tagName, 39 | $remote 40 | )); 41 | 42 | $command = sprintf('git push %s %s', $remote, $tagName); 43 | $exec = $this->exec; 44 | $output = []; 45 | $return = 0; 46 | 47 | $exec($command, $output, $return); 48 | 49 | if (0 !== $return) { 50 | $event->taggingFailed(); 51 | return; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Version/ReadyCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 37 | parent::__construct($name); 38 | } 39 | 40 | protected function configure(): void 41 | { 42 | $this->setDescription(self::DESCRIPTION); 43 | $this->setHelp(self::HELP); 44 | $this->addOption( 45 | 'date', 46 | '-d', 47 | InputOption::VALUE_REQUIRED, 48 | 'Specific date string to use; use this if the date is other than today,' 49 | . ' or if you wish to use a different date format.' 50 | ); 51 | $this->addOption( 52 | 'date', 53 | '-d', 54 | InputOption::VALUE_REQUIRED, 55 | 'Specific date string to use; use this if the date is other than today,' 56 | . ' or if you wish to use a different date format.' 57 | ); 58 | $this->addOption( 59 | 'release-version', 60 | 'r', 61 | InputOption::VALUE_REQUIRED, 62 | 'A specific changelog version to ready for release.' 63 | ); 64 | } 65 | 66 | protected function execute(InputInterface $input, OutputInterface $output): int 67 | { 68 | return $this->dispatcher 69 | ->dispatch(new ReadyLatestChangelogEvent( 70 | $input, 71 | $output, 72 | $this->dispatcher, 73 | $input->getOption('date') ?: date('Y-m-d'), 74 | $input->getOption('release-version') ?: null 75 | )) 76 | ->failed() 77 | ? 1 78 | : 0; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Version/ReadyLatestChangelogEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 37 | $this->output = $output; 38 | $this->dispatcher = $dispatcher; 39 | $this->releaseDate = $releaseDate; 40 | $this->version = $version; 41 | } 42 | 43 | public function isPropagationStopped(): bool 44 | { 45 | return $this->failed; 46 | } 47 | 48 | public function releaseDate(): string 49 | { 50 | return $this->releaseDate; 51 | } 52 | 53 | public function malformedReleaseLine(string $versionLine): void 54 | { 55 | $this->failed = true; 56 | $this->output->writeln( 57 | 'Unable to set release date; most recent release has a malformed release line.' 58 | ); 59 | $this->output->writeln('Must be in the following format (minus initial indentation):'); 60 | $this->output->writeln(' ## - TBD'); 61 | $this->output->writeln('where follows semantic versioning rules.'); 62 | $this->output->writeln(''); 63 | $this->output->writeln('Discovered:'); 64 | $this->output->writeln(sprintf(' %s', $versionLine)); 65 | } 66 | 67 | public function changelogReady(): void 68 | { 69 | $versionString = $this->version 70 | ? sprintf('changelog version %s', $this->version) 71 | : 'most recent changelog'; 72 | $this->output->writeln(sprintf( 73 | 'Set release date of %s to "%s"', 74 | $versionString, 75 | $this->releaseDate 76 | )); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Version/ReleaseCommandConfigListener.php: -------------------------------------------------------------------------------- 1 | input = $input; 37 | $this->output = $output; 38 | $this->dispatcher = $dispatcher; 39 | $this->version = $version; 40 | } 41 | 42 | public function isPropagationStopped(): bool 43 | { 44 | return $this->aborted || $this->failed; 45 | } 46 | 47 | public function abort() 48 | { 49 | $this->aborted = true; 50 | $this->output->writeln('Aborting at user request'); 51 | } 52 | 53 | public function versionRemoved() 54 | { 55 | $this->output->writeln(sprintf( 56 | 'Removed changelog version %s from file %s.', 57 | $this->version, 58 | $this->config()->changelogFile() 59 | )); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Version/RemoveChangelogVersionListener.php: -------------------------------------------------------------------------------- 1 | config()->changelogFile(); 20 | $entry = $event->changelogEntry(); 21 | 22 | $this->getChangelogEditor()->update($changelog, '', $entry); 23 | 24 | $event->versionRemoved(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Version/RemoveCommand.php: -------------------------------------------------------------------------------- 1 | provided. The command will provide a preview, and prompt for 25 | confirmation before doing so (unless using the --force-removal flag). 26 | EOH; 27 | 28 | /** @var EventDispatcherInterface */ 29 | private $dispatcher; 30 | 31 | public function __construct(EventDispatcherInterface $dispatcher, ?string $name = null) 32 | { 33 | $this->dispatcher = $dispatcher; 34 | parent::__construct($name); 35 | } 36 | 37 | protected function configure(): void 38 | { 39 | $this->setDescription(self::DESCRIPTION); 40 | $this->setHelp(self::HELP); 41 | $this->addArgument( 42 | 'version', 43 | InputArgument::REQUIRED, 44 | 'The changelog version to remove.' 45 | ); 46 | $this->addOption( 47 | 'force-removal', 48 | 'r', 49 | InputOption::VALUE_NONE, 50 | 'Do not prompt for confirmation.' 51 | ); 52 | } 53 | 54 | protected function execute(InputInterface $input, OutputInterface $output): int 55 | { 56 | return $this->dispatcher 57 | ->dispatch(new RemoveChangelogVersionEvent( 58 | $input, 59 | $output, 60 | $this->dispatcher, 61 | $input->getArgument('version') 62 | )) 63 | ->failed() 64 | ? 1 65 | : 0; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Version/SetDateForChangelogReleaseListener.php: -------------------------------------------------------------------------------- 1 | changelogEntry(); 25 | $lines = explode("\n", $entry->contents); 26 | $versionLine = $lines[0]; 27 | 28 | if (null === ($versionLine = $this->injectDate($versionLine, $event->releaseDate()))) { 29 | $event->malformedReleaseLine($lines[0]); 30 | return; 31 | } 32 | 33 | $lines[0] = $versionLine; 34 | 35 | $this->getChangelogEditor()->update( 36 | $event->config()->changelogFile(), 37 | implode("\n", $lines), 38 | $entry 39 | ); 40 | 41 | $event->changelogReady(); 42 | } 43 | 44 | private function injectDate(string $versionLine, string $date): ?string 45 | { 46 | // @phpcs:disable 47 | $regex = '/^(?P## \d+\.\d+\.\d+(?:(alpha|beta|rc|dev|patch|pl|a|b|p)\d+)?)\s+-\s+(?:(?!\d{4}-\d{2}-\d{2}).*)/i'; 48 | // @phpcs:enable 49 | 50 | if (! preg_match($regex, $versionLine, $matches)) { 51 | return null; 52 | } 53 | 54 | return sprintf( 55 | '%s - %s', 56 | $matches['prefix'], 57 | $date 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Version/ShowCommand.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 31 | parent::__construct($name); 32 | } 33 | 34 | protected function configure(): void 35 | { 36 | $this->setDescription(self::DESCRIPTION); 37 | $this->setHelp(self::HELP); 38 | $this->addArgument( 39 | 'version', 40 | InputArgument::OPTIONAL, 41 | 'Which version do you wish to display? (Defaults to latest)' 42 | ); 43 | } 44 | 45 | protected function execute(InputInterface $input, OutputInterface $output): int 46 | { 47 | return $this->dispatcher 48 | ->dispatch(new ShowVersionEvent( 49 | $input, 50 | $output, 51 | $this->dispatcher, 52 | $input->getArgument('version') ?: null 53 | )) 54 | ->failed() 55 | ? 1 56 | : 0; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Version/ShowVersionEvent.php: -------------------------------------------------------------------------------- 1 | input = $input; 31 | $this->output = $output; 32 | $this->dispatcher = $dispatcher; 33 | $this->version = $version; 34 | } 35 | 36 | public function isPropagationStopped(): bool 37 | { 38 | return $this->failed; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Version/ShowVersionListener.php: -------------------------------------------------------------------------------- 1 | changelogEntry(); 16 | $event->output()->writeln($changelogEntry->contents); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Version/TagCommandConfigListener.php: -------------------------------------------------------------------------------- 1 | version(); 23 | 24 | $event->output()->writeln(sprintf('Preparing to tag version %s', $version)); 25 | 26 | if ( 27 | ! $this->tagWithChangelog( 28 | $event->tagName(), 29 | $event->package(), 30 | $version, 31 | $event->changelog() 32 | ) 33 | ) { 34 | $event->tagOperationFailed(); 35 | return; 36 | } 37 | 38 | $event->taggingComplete(); 39 | } 40 | 41 | private function tagWithChangelog(string $tagName, string $package, string $version, string $changelog): bool 42 | { 43 | $tempFile = tempnam(sys_get_temp_dir(), 'KAC'); 44 | file_put_contents($tempFile, sprintf("%s %s\n\n%s", $package, $version, $changelog)); 45 | 46 | $command = sprintf('git tag -s -F %s %s', $tempFile, $tagName); 47 | system($command, $return); 48 | 49 | unlink($tempFile); 50 | 51 | return 0 === $return; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Version/ValidateVersionToUseListener.php: -------------------------------------------------------------------------------- 1 | version()) { 19 | // null is a valid version for this workflow; equates to "most recent" 20 | return; 21 | } 22 | 23 | parent::__invoke($event); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Version/VerifyProviderCanReleaseListener.php: -------------------------------------------------------------------------------- 1 | config(); 16 | $providerSpec = $config->provider(); 17 | 18 | if (! $providerSpec->isComplete()) { 19 | $event->providerIsIncomplete(); 20 | return; 21 | } 22 | 23 | $provider = $providerSpec->createProvider(); 24 | 25 | if (! $provider->canCreateRelease()) { 26 | $event->providerIsIncomplete(); 27 | return; 28 | } 29 | 30 | $event->discoveredProvider($provider); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Version/VerifyTagExistsListener.php: -------------------------------------------------------------------------------- 1 | 21 | * function(string $command[, array &$output[, int &$return]]) 22 | * 23 | * 24 | * @var callable 25 | */ 26 | public $exec = 'exec'; 27 | 28 | public function __invoke(ReleaseEvent $event): void 29 | { 30 | $command = sprintf('git show %s', $event->tagName()); 31 | $exec = $this->exec; 32 | $exec($command, $output, $return); 33 | if (0 !== $return) { 34 | $event->couldNotFindTag(); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Version/VerifyVersionHasReleaseDateListener.php: -------------------------------------------------------------------------------- 1 | findReleaseDateForVersion( 24 | file_get_contents($event->config()->changelogFile()), 25 | $event->version(), 26 | true 27 | ); 28 | } catch (ChangelogMissingDateException $e) { 29 | $event->changelogMissingDate(); 30 | } 31 | } 32 | } 33 | --------------------------------------------------------------------------------