├── .yamllint.yaml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── github-changelog ├── github-changelog.php ├── phive.xml └── src ├── Console └── GenerateCommand.php ├── Exception ├── ExceptionInterface.php ├── InvalidArgumentException.php ├── PullRequestNotFound.php ├── ReferenceNotFound.php └── RuntimeException.php ├── Repository ├── CommitRepository.php ├── CommitRepositoryInterface.php ├── PullRequestRepository.php └── PullRequestRepositoryInterface.php ├── Resource ├── Commit.php ├── CommitInterface.php ├── PullRequest.php ├── PullRequestInterface.php ├── Range.php ├── RangeInterface.php ├── Repository.php ├── RepositoryInterface.php ├── User.php └── UserInterface.php └── Util ├── Git.php ├── GitInterface.php ├── RepositoryResolver.php └── RepositoryResolverInterface.php /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | extends: "default" 2 | 3 | ignore: | 4 | .build/ 5 | .notes/ 6 | vendor/ 7 | 8 | rules: 9 | braces: 10 | max-spaces-inside-empty: 0 11 | max-spaces-inside: 1 12 | min-spaces-inside-empty: 0 13 | min-spaces-inside: 1 14 | brackets: 15 | max-spaces-inside-empty: 0 16 | max-spaces-inside: 0 17 | min-spaces-inside-empty: 0 18 | min-spaces-inside: 0 19 | colons: 20 | max-spaces-after: 1 21 | max-spaces-before: 0 22 | commas: 23 | max-spaces-after: 1 24 | max-spaces-before: 0 25 | min-spaces-after: 1 26 | comments: 27 | ignore-shebangs: true 28 | min-spaces-from-content: 1 29 | require-starting-space: true 30 | comments-indentation: "enable" 31 | document-end: 32 | present: false 33 | document-start: 34 | present: false 35 | indentation: 36 | check-multi-line-strings: false 37 | indent-sequences: true 38 | spaces: 2 39 | empty-lines: 40 | max-end: 0 41 | max-start: 0 42 | max: 1 43 | empty-values: 44 | forbid-in-block-mappings: true 45 | forbid-in-flow-mappings: true 46 | hyphens: 47 | max-spaces-after: 2 48 | key-duplicates: "enable" 49 | key-ordering: "disable" 50 | line-length: "disable" 51 | new-line-at-end-of-file: "enable" 52 | new-lines: 53 | type: "unix" 54 | octal-values: 55 | forbid-implicit-octal: true 56 | quoted-strings: 57 | quote-type: "double" 58 | trailing-spaces: "enable" 59 | truthy: 60 | allowed-values: 61 | - "false" 62 | - "true" 63 | 64 | yaml-files: 65 | - "*.yaml" 66 | - "*.yml" 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## Unreleased 8 | 9 | For a full diff see [`0.7.1...main`][0.7.1...main]. 10 | 11 | ### Changed 12 | 13 | * Dropped support for PHP 7.2 ([#504]), by [@localheinz] 14 | * Dropped support for PHP 7.3 ([#506]), by [@localheinz] 15 | 16 | ## [`0.7.1`][0.7.1] 17 | 18 | For a full diff see [`0.7.0...0.7.1`][0.7.0...0.7.1]. 19 | 20 | ### Fixed 21 | 22 | * Removed an inappropriate `replace` configuration from `composer.json` ([#339]), by [@localheinz] 23 | 24 | ## [`0.7.0`][0.7.0] 25 | 26 | For a full diff see [`0.6.1...0.7.0`][0.6.1...0.7.0]. 27 | 28 | ### Changed 29 | 30 | * Renamed namespace `Localheinz\GitHub\ChangeLog` to `Ergebnis\GitHub\Changelog` after move to [@ergebnis] ([#336]), by [@localheinz] 31 | 32 | Run 33 | 34 | ``` 35 | $ composer remove localheinz/github-changelog 36 | ``` 37 | 38 | and 39 | 40 | ``` 41 | $ composer require ergebnis/github-changelog 42 | ``` 43 | 44 | to update. 45 | 46 | Run 47 | 48 | ``` 49 | $ find . -type f -exec sed -i '.bak' 's/Localheinz\\GitHub\\ChangeLog/Ergebnis\\GitHub\\Changelog/g' {} \; 50 | ``` 51 | 52 | to replace occurrences of `Localheinz\GitHub\ChangeLog` with `Ergebnis\GitHub\Changelog`. 53 | 54 | Run 55 | 56 | ``` 57 | $ find -type f -name '*.bak' -delete 58 | ``` 59 | 60 | to delete backup files created in the previous step. 61 | 62 | ### Fixed 63 | 64 | * Dropped support for PHP 7.1 ([#314]), by [@localheinz] 65 | 66 | [0.7.0]: https://github.com/ergebnis/github-changelog/tag/0.7.0 67 | [0.7.1]: https://github.com/ergebnis/github-changelog/tag/0.7.0 68 | 69 | [0.6.1...0.7.0]: https://github.com/ergebnis/github-changelog/compare/0.6.1...0.7.0 70 | [0.7.0...0.7.1]: https://github.com/ergebnis/github-changelog/compare/0.7.0...0.7.1 71 | [0.7.1...main]: https://github.com/ergebnis/github-changelog/compare/0.7.1...main 72 | 73 | [#314]: https://github.com/ergebnis/github-changelog/pull/314 74 | [#336]: https://github.com/ergebnis/github-changelog/pull/336 75 | [#339]: https://github.com/ergebnis/github-changelog/pull/339 76 | [#504]: https://github.com/ergebnis/github-changelog/pull/504 77 | [#506]: https://github.com/ergebnis/github-changelog/pull/506 78 | 79 | [@ergebnis]: https://github.com/ergebnis 80 | [@localheinz]: https://github.com/localheinz 81 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2022 Andreas Möller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the _Software_), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | Software. 12 | 13 | THE SOFTWARE IS PROVIDED **AS IS**, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-changelog 2 | 3 | [![Integrate](https://github.com/ergebnis/github-changelog/workflows/Integrate/badge.svg)](https://github.com/ergebnis/github-changelog/actions) 4 | [![Prune](https://github.com/ergebnis/github-changelog/workflows/Prune/badge.svg)](https://github.com/ergebnis/github-changelog/actions) 5 | [![Release](https://github.com/ergebnis/github-changelog/workflows/Release/badge.svg)](https://github.com/ergebnis/github-changelog/actions) 6 | [![Renew](https://github.com/ergebnis/github-changelog/workflows/Renew/badge.svg)](https://github.com/ergebnis/github-changelog/actions) 7 | 8 | [![Code Coverage](https://codecov.io/gh/ergebnis/github-changelog/branch/main/graph/badge.svg)](https://codecov.io/gh/ergebnis/github-changelog) 9 | [![Type Coverage](https://shepherd.dev/github/ergebnis/github-changelog/coverage.svg)](https://shepherd.dev/github/ergebnis/github-changelog) 10 | 11 | [![Latest Stable Version](https://poser.pugx.org/ergebnis/github-changelog/v/stable)](https://packagist.org/packages/ergebnis/github-changelog) 12 | [![Total Downloads](https://poser.pugx.org/ergebnis/github-changelog/downloads)](https://packagist.org/packages/ergebnis/github-changelog) 13 | 14 | Provides a script that generates a changelog based on titles of pull requests merged between specified references. 15 | 16 | ## Is this the right tool for me? 17 | 18 | Probably not. There are a range of other tools that probably do a better job. 19 | 20 | Take a look at 21 | 22 | * https://packagist.org/?q=changelog 23 | * https://www.npmjs.com/search?q=changelog 24 | * https://pypi.python.org/pypi?%3Aaction=search&term=changelog 25 | 26 | Nonetheless, for me and my projects, it's the second best thing after *manually* 27 | keeping a changelog as suggested at http://keepachangelog.com. 28 | 29 | ## When will it work for me? 30 | 31 | | My process | Will this tool work for me? | 32 | |:---------------------------------------------|:----------------------------| 33 | | I need elaborate changelogs | No | 34 | | I push directly into the default branch | No | 35 | | ![Rebase and merge][rebase-and-merge-button] | No | 36 | | ![Squash and merge][squash-and-merge-button] | No | 37 | | ![Merge pull request][merge-button] | **Yes** | 38 | 39 | [rebase-and-merge-button]: https://user-images.githubusercontent.com/605483/30547612-18674f5c-9c90-11e7-8c0c-b300a8abb30c.png 40 | [squash-and-merge-button]: https://user-images.githubusercontent.com/605483/30547621-1e1683fa-9c90-11e7-8233-fe41629d84d6.png 41 | [merge-button]: https://user-images.githubusercontent.com/605483/30547611-18656e26-9c90-11e7-9dd3-c49aaa9bb4bf.png 42 | 43 | ## Why is this tool so limited? 44 | 45 | All this tool does is this: 46 | 47 | - it collects commits between references 48 | - it matches commit messages against what is used by GitHub as a merge commit message 49 | - it fetches the pull request title from the corresponding pull request 50 | - it then uses all of the pull request titles to compile a list 51 | 52 | ## CLI Tool 53 | 54 | ### Global installation 55 | 56 | Install globally: 57 | 58 | ```sh 59 | $ composer global require ergebnis/github-changelog 60 | ``` 61 | 62 | Create your changelogs from within a Git repository: 63 | 64 | ```sh 65 | $ git clone git@github.com:ergebnis/github-changelog.git 66 | $ cd github-changelog 67 | $ github-changelog generate 0.1.1 0.1.2 68 | ``` 69 | 70 | Create your changelogs from anywhere, specifying the repository using the `--repository` option: 71 | 72 | ```sh 73 | $ github-changelog generate --repository ergebnis/github-changelog 0.1.1 0.1.2 74 | ``` 75 | 76 | Enjoy the changelog: 77 | 78 | ``` 79 | - Fix: Catch exceptions in command (#37), by @localheinz 80 | - Fix: Request 250 instead of 30 commits (#38), by @localheinz 81 | ``` 82 | 83 | ### Local installation 84 | 85 | Install locally: 86 | 87 | ```sh 88 | $ composer require --dev ergebnis/github-changelog 89 | ``` 90 | 91 | Create your changelog from within in your project: 92 | 93 | ```sh 94 | $ vendor/bin/github-changelog generate ergebnis/github-changelog ae63248 main 95 | ``` 96 | 97 | Enjoy the changelog: 98 | 99 | ``` 100 | - Enhancement: Create ChangeLog command (#31), by @localheinz 101 | - Fix: Assert exit code is set to 0 (#32), by @localheinz 102 | - Enhancement: Add console application (#33), by @localheinz 103 | - Fix: Readme (#34), by @localheinz 104 | - Fix: Autoloading for console script (#35), by @localheinz 105 | - Fix: Version foo with rebasing and whatnot (#36), by @localheinz 106 | - Fix: Catch exceptions in command (#37), by @localheinz 107 | - Fix: Request 250 instead of 30 commits (#38), by @localheinz 108 | ``` 109 | 110 | ## Userland Code 111 | 112 | Install locally: 113 | 114 | ```sh 115 | $ composer require ergebnis/github-changelog 116 | ``` 117 | 118 | Retrieve pull requests between references in your application: 119 | 120 | ```php 121 | authenticate( 132 | 'your-token-here', 133 | Client::AUTH_HTTP_TOKEN 134 | ); 135 | 136 | $pullRequestRepository = new Repository\PullRequestRepository( 137 | $client->pullRequests(), 138 | new Repository\CommitRepository($client->repositories()->commits()) 139 | ); 140 | 141 | /* @var Resource\RangeInterface $range */ 142 | $range = $repository->items( 143 | Resource\Repository::fromString('ergebnis/github-changelog'), 144 | '0.1.1', 145 | '0.1.2' 146 | ); 147 | 148 | $pullRequests = $range->pullRequests(); 149 | 150 | array_walk($pullRequests, function (Resource\PullRequestInterface $pullRequest) { 151 | echo sprintf( 152 | '- %s (#%d), submitted by @%s' . PHP_EOL, 153 | $pullRequest->title(), 154 | $pullRequest->number(), 155 | $pullRequest->author()->login(), 156 | ); 157 | }); 158 | 159 | ``` 160 | 161 | Enjoy the changelog: 162 | 163 | ``` 164 | - Fix: Catch exceptions in command (#37), submitted by @localheinz 165 | - Fix: Request 250 instead of 30 commits (#38), submitted by @localheinz 166 | ``` 167 | 168 | ## Hints 169 | 170 | :bulb: You can use anything for a reference, e.g., a tag, a branch, a commit! 171 | 172 | ## Changelog 173 | 174 | Please have a look at [`CHANGELOG.md`](CHANGELOG.md). 175 | 176 | ## Contributing 177 | 178 | Please have a look at [`CONTRIBUTING.md`](.github/CONTRIBUTING.md). 179 | 180 | ## Code of Conduct 181 | 182 | Please have a look at [`CODE_OF_CONDUCT.md`](https://github.com/ergebnis/.github/blob/main/CODE_OF_CONDUCT.md). 183 | 184 | ## License 185 | 186 | This package is licensed using the MIT License. 187 | 188 | Please have a look at [`LICENSE.md`](LICENSE.md). 189 | 190 | ## Curious what I am building? 191 | 192 | :mailbox_with_mail: [Subscribe to my list](https://localheinz.com/projects/), and I will occasionally send you an email to let you know what I am working on. 193 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ergebnis/github-changelog", 3 | "description": "Provides a console command that generates a changelog based on titles of pull requests merged between specified references.", 4 | "homepage": "https://github.com/ergebnis/github-changelog", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Andreas Möller", 9 | "email": "am@localheinz.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.4", 14 | "knplabs/github-api": "^2.19.0", 15 | "php-http/guzzle6-adapter": "^1.1.1", 16 | "symfony/cache": "^5.2.4", 17 | "symfony/console": "^5.2.4", 18 | "symfony/stopwatch": "^5.2.4" 19 | }, 20 | "require-dev": { 21 | "ergebnis/composer-normalize": "^2.13.3", 22 | "ergebnis/license": "^1.1.0", 23 | "ergebnis/php-cs-fixer-config": "^2.5.3", 24 | "ergebnis/phpstan-rules": "~0.15.3", 25 | "ergebnis/test-util": "^1.4.0", 26 | "infection/infection": "~0.21", 27 | "jangregor/phpstan-prophecy": "~0.8.1", 28 | "phpspec/prophecy-phpunit": "^2.0.1", 29 | "phpstan/extension-installer": "^1.1.0", 30 | "phpstan/phpstan": "~0.12.80", 31 | "phpstan/phpstan-deprecation-rules": "~0.12.6", 32 | "phpstan/phpstan-phpunit": "~0.12.18", 33 | "phpstan/phpstan-strict-rules": "~0.12.9", 34 | "phpunit/phpunit": "^9.2.6", 35 | "psalm/plugin-phpunit": "~0.15.1", 36 | "vimeo/psalm": "^4.6.2" 37 | }, 38 | "config": { 39 | "platform": { 40 | "php": "7.4.7" 41 | }, 42 | "preferred-install": "dist", 43 | "sort-packages": true 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Ergebnis\\GitHub\\Changelog\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "Ergebnis\\GitHub\\Changelog\\Test\\": "test/" 53 | } 54 | }, 55 | "bin": [ 56 | "github-changelog" 57 | ], 58 | "support": { 59 | "issues": "https://github.com/ergebnis/github-changelog/issues", 60 | "source": "https://github.com/ergebnis/github-changelog" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /github-changelog: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | addCache(new Cache\Adapter\FilesystemAdapter( 37 | '', 38 | 0, 39 | __DIR__ . '/data/cache' 40 | )); 41 | 42 | $application = new Console\Application('github-changelog', '0.5.2'); 43 | 44 | $application->add(new Changelog\Console\GenerateCommand( 45 | $client, 46 | new Changelog\Repository\PullRequestRepository( 47 | new Api\PullRequest($client), 48 | new Changelog\Repository\CommitRepository(new Api\Repository\Commits($client)) 49 | ), 50 | new Changelog\Util\RepositoryResolver(new Changelog\Util\Git()), 51 | new Stopwatch\Stopwatch() 52 | )); 53 | 54 | $application->run(); 55 | -------------------------------------------------------------------------------- /phive.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/Console/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | client = $client; 43 | $this->pullRequestRepository = $pullRequestRepository; 44 | $this->repositoryResolver = $repositoryResolver; 45 | $this->stopwatch = $stopwatch; 46 | } 47 | 48 | protected function configure(): void 49 | { 50 | $this 51 | ->setName('generate') 52 | ->setDescription('Generates a changelog from merged pull requests found between commit references') 53 | ->addArgument( 54 | 'start-reference', 55 | Console\Input\InputArgument::REQUIRED, 56 | 'The start reference, e.g. "1.0.0"' 57 | ) 58 | ->addArgument( 59 | 'end-reference', 60 | Console\Input\InputArgument::OPTIONAL, 61 | 'The end reference, e.g. "1.1.0"' 62 | ) 63 | ->addOption( 64 | 'auth-token', 65 | 'a', 66 | Console\Input\InputOption::VALUE_REQUIRED, 67 | 'The GitHub token' 68 | ) 69 | ->addOption( 70 | 'repository', 71 | 'r', 72 | Console\Input\InputOption::VALUE_REQUIRED, 73 | 'The repository, e.g. "ergebnis/github-changelog"' 74 | ) 75 | ->addOption( 76 | 'template', 77 | 't', 78 | Console\Input\InputOption::VALUE_REQUIRED, 79 | 'The template to use for rendering a pull request', 80 | '- %pullrequest.title% (#%pullrequest.number%), by @%pullrequest.author.login%' 81 | ); 82 | } 83 | 84 | protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int 85 | { 86 | $this->stopwatch->start('changelog'); 87 | 88 | $io = new Console\Style\SymfonyStyle( 89 | $input, 90 | $output 91 | ); 92 | 93 | $io->title('Localheinz GitHub Changelog'); 94 | 95 | $authToken = $input->getOption('auth-token'); 96 | 97 | if (\is_string($authToken)) { 98 | $this->client->authenticate( 99 | $authToken, 100 | Client::AUTH_ACCESS_TOKEN 101 | ); 102 | } 103 | 104 | $repositoryName = $input->getOption('repository'); 105 | 106 | if (\is_string($repositoryName)) { 107 | try { 108 | $repository = Resource\Repository::fromString($repositoryName); 109 | } catch (Exception\InvalidArgumentException $exception) { 110 | $io->error(\sprintf( 111 | 'Repository "%s" appears to be invalid.', 112 | $repositoryName 113 | )); 114 | 115 | return 1; 116 | } 117 | } else { 118 | try { 119 | $repository = $this->repositoryResolver->resolve( 120 | 'upstream', 121 | 'origin' 122 | ); 123 | } catch (Exception\RuntimeException $exception) { 124 | $io->error('Unable to resolve repository, please specify using --repository option.'); 125 | 126 | return 1; 127 | } 128 | } 129 | 130 | /** @var string $startReference */ 131 | $startReference = $input->getArgument('start-reference'); 132 | 133 | /** @var string $endReference */ 134 | $endReference = $input->getArgument('end-reference'); 135 | 136 | $range = $this->range( 137 | $startReference, 138 | $endReference 139 | ); 140 | 141 | $io->section(\sprintf( 142 | 'Pull Requests merged in %s %s', 143 | $repository, 144 | $range 145 | )); 146 | 147 | try { 148 | $range = $this->pullRequestRepository->items( 149 | $repository, 150 | $startReference, 151 | $endReference 152 | ); 153 | } catch (\Exception $exception) { 154 | $io->error(\sprintf( 155 | 'An error occurred: %s', 156 | $exception->getMessage() 157 | )); 158 | 159 | return 1; 160 | } 161 | 162 | $pullRequests = $range->pullRequests(); 163 | 164 | if (0 === \count($pullRequests)) { 165 | $io->warning('Could not find any pull requests'); 166 | } else { 167 | /** @var string $template */ 168 | $template = $input->getOption('template'); 169 | 170 | $pullRequests = \array_reverse($pullRequests); 171 | 172 | \array_walk($pullRequests, static function (Resource\PullRequestInterface $pullRequest) use ($output, $template): void { 173 | $message = \str_replace( 174 | [ 175 | '%pullrequest.title%', 176 | '%pullrequest.number%', 177 | '%pullrequest.author.login%', 178 | '%pullrequest.author.htmlUrl%', 179 | ], 180 | [ 181 | $pullRequest->title(), 182 | (string) $pullRequest->number(), 183 | $pullRequest->author()->login(), 184 | $pullRequest->author()->htmlUrl(), 185 | ], 186 | $template 187 | ); 188 | 189 | $output->writeln($message); 190 | }); 191 | 192 | $io->newLine(); 193 | 194 | $io->success(\sprintf( 195 | 'Found %d pull request%s.', 196 | \count($pullRequests), 197 | 1 === \count($pullRequests) ? '' : 's' 198 | )); 199 | } 200 | 201 | $event = $this->stopwatch->stop('changelog'); 202 | 203 | $io->writeln($this->formatStopwatchEvent($event)); 204 | 205 | return 0; 206 | } 207 | 208 | private function range(string $startReference, ?string $endReference = null): string 209 | { 210 | if (null === $endReference) { 211 | return \sprintf( 212 | 'since %s', 213 | $startReference 214 | ); 215 | } 216 | 217 | return \sprintf( 218 | 'between %s and %s', 219 | $startReference, 220 | $endReference 221 | ); 222 | } 223 | 224 | private function formatStopwatchEvent(Stopwatch\StopwatchEvent $event): string 225 | { 226 | return \sprintf( 227 | 'Time: %s, Memory: %s.', 228 | Console\Helper\Helper::formatTime($event->getDuration() / 1000), 229 | Console\Helper\Helper::formatMemory($event->getMemory()) 230 | ); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | api = $api; 27 | } 28 | 29 | public function items(Resource\RepositoryInterface $repository, string $startReference, ?string $endReference = null): Resource\RangeInterface 30 | { 31 | if ($startReference === $endReference) { 32 | return new Resource\Range(); 33 | } 34 | 35 | try { 36 | $start = $this->show( 37 | $repository, 38 | $startReference 39 | ); 40 | } catch (Exception\ReferenceNotFound $exception) { 41 | return new Resource\Range(); 42 | } 43 | 44 | $params = []; 45 | 46 | if (null !== $endReference) { 47 | try { 48 | $end = $this->show( 49 | $repository, 50 | $endReference 51 | ); 52 | } catch (Exception\ReferenceNotFound $exception) { 53 | return new Resource\Range(); 54 | } 55 | 56 | $params = [ 57 | 'sha' => $end->sha(), 58 | ]; 59 | } 60 | 61 | $commits = $this->all($repository, $params)->commits(); 62 | 63 | $range = new Resource\Range(); 64 | 65 | $tail = null; 66 | 67 | while (\count($commits)) { 68 | /** @var Resource\CommitInterface $commit */ 69 | $commit = \array_shift($commits); 70 | 71 | if ($tail instanceof Resource\CommitInterface && $commit->equals($tail)) { 72 | continue; 73 | } 74 | 75 | if ($commit->equals($start)) { 76 | break; 77 | } 78 | 79 | $range = $range->withCommit($commit); 80 | 81 | if (0 === \count($commits)) { 82 | $tail = $commit; 83 | $params = [ 84 | 'sha' => $tail->sha(), 85 | ]; 86 | 87 | $commits = $this->all($repository, $params)->commits(); 88 | } 89 | } 90 | 91 | return $range; 92 | } 93 | 94 | public function show(Resource\RepositoryInterface $repository, string $sha): Resource\CommitInterface 95 | { 96 | $response = $this->api->show( 97 | $repository->owner(), 98 | $repository->name(), 99 | $sha 100 | ); 101 | 102 | if (!\is_array($response)) { 103 | throw Exception\ReferenceNotFound::fromRepositoryAndReference( 104 | $repository, 105 | $sha 106 | ); 107 | } 108 | 109 | return new Resource\Commit( 110 | $response['sha'], 111 | $response['commit']['message'] 112 | ); 113 | } 114 | 115 | public function all(Resource\RepositoryInterface $repository, array $params = []): Resource\RangeInterface 116 | { 117 | $range = new Resource\Range(); 118 | 119 | if (!\array_key_exists('per_page', $params)) { 120 | $params['per_page'] = 250; 121 | } 122 | 123 | $response = $this->api->all( 124 | $repository->owner(), 125 | $repository->name(), 126 | $params 127 | ); 128 | 129 | if (!\is_array($response)) { 130 | return $range; 131 | } 132 | 133 | \array_walk($response, static function ($data) use (&$range): void { 134 | $commit = new Resource\Commit( 135 | $data['sha'], 136 | $data['commit']['message'] 137 | ); 138 | 139 | $range = $range->withCommit($commit); 140 | }); 141 | 142 | return $range; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Repository/CommitRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | api = $api; 29 | $this->commitRepository = $commitRepository; 30 | } 31 | 32 | public function show(Resource\RepositoryInterface $repository, int $number): Resource\PullRequestInterface 33 | { 34 | $response = $this->api->show( 35 | $repository->owner(), 36 | $repository->name(), 37 | $number 38 | ); 39 | 40 | if (!\is_array($response)) { 41 | throw Exception\PullRequestNotFound::fromRepositoryAndNumber( 42 | $repository, 43 | $number 44 | ); 45 | } 46 | 47 | return new Resource\PullRequest( 48 | $response['number'], 49 | $response['title'], 50 | new Resource\User($response['user']['login']) 51 | ); 52 | } 53 | 54 | public function items(Resource\RepositoryInterface $repository, string $startReference, ?string $endReference = null): Resource\RangeInterface 55 | { 56 | $range = $this->commitRepository->items( 57 | $repository, 58 | $startReference, 59 | $endReference 60 | ); 61 | 62 | $commits = $range->commits(); 63 | 64 | \array_walk($commits, function (Resource\CommitInterface $commit) use (&$range, $repository): void { 65 | if (0 === \preg_match('/^Merge pull request #(?P\d+)/', $commit->message(), $matches)) { 66 | return; 67 | } 68 | 69 | $number = (int) $matches['number']; 70 | 71 | try { 72 | $pullRequest = $this->show( 73 | $repository, 74 | $number 75 | ); 76 | } catch (Exception\PullRequestNotFound $exception) { 77 | return; 78 | } 79 | 80 | $range = $range->withPullRequest($pullRequest); 81 | }); 82 | 83 | return $range; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Repository/PullRequestRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | sha = $sha; 37 | $this->message = $message; 38 | } 39 | 40 | public function sha(): string 41 | { 42 | return $this->sha; 43 | } 44 | 45 | public function message(): string 46 | { 47 | return $this->message; 48 | } 49 | 50 | public function equals(CommitInterface $commit): bool 51 | { 52 | return $commit->sha() === $this->sha(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Resource/CommitInterface.php: -------------------------------------------------------------------------------- 1 | $number) { 32 | throw new Exception\InvalidArgumentException(\sprintf( 33 | 'Number "%d" does not appear to be a valid pull request number.', 34 | $number 35 | )); 36 | } 37 | 38 | $this->number = $number; 39 | $this->title = $title; 40 | $this->author = $author; 41 | } 42 | 43 | public function number(): int 44 | { 45 | return $this->number; 46 | } 47 | 48 | public function title(): string 49 | { 50 | return $this->title; 51 | } 52 | 53 | public function author(): UserInterface 54 | { 55 | return $this->author; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Resource/PullRequestInterface.php: -------------------------------------------------------------------------------- 1 | commits; 31 | } 32 | 33 | public function pullRequests(): array 34 | { 35 | return $this->pullRequests; 36 | } 37 | 38 | public function withCommit(CommitInterface $commit): RangeInterface 39 | { 40 | $instance = clone $this; 41 | 42 | $instance->commits[] = $commit; 43 | 44 | return $instance; 45 | } 46 | 47 | public function withPullRequest(PullRequestInterface $pullRequest): RangeInterface 48 | { 49 | $instance = clone $this; 50 | 51 | $instance->pullRequests[] = $pullRequest; 52 | 53 | return $instance; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Resource/RangeInterface.php: -------------------------------------------------------------------------------- 1 | owner = $owner; 27 | $this->name = $name; 28 | } 29 | 30 | public function __toString(): string 31 | { 32 | return \sprintf( 33 | '%s/%s', 34 | $this->owner, 35 | $this->name 36 | ); 37 | } 38 | 39 | /** 40 | * @throws Exception\InvalidArgumentException 41 | */ 42 | public static function fromOwnerAndName(string $owner, string $name): self 43 | { 44 | if (1 !== \preg_match(self::ownerRegEx(), $owner)) { 45 | throw new Exception\InvalidArgumentException(\sprintf( 46 | 'Owner "%s" does not appear to be a valid owner.', 47 | $owner 48 | )); 49 | } 50 | 51 | if (1 !== \preg_match(self::nameRegEx(), $name)) { 52 | throw new Exception\InvalidArgumentException(\sprintf( 53 | 'Name "%s" does not appear to be a valid name.', 54 | $name 55 | )); 56 | } 57 | 58 | return new self( 59 | $owner, 60 | $name 61 | ); 62 | } 63 | 64 | public static function fromRemoteUrl(string $remoteUrl): self 65 | { 66 | $regEx = self::remoteUrlRegEx(); 67 | 68 | if (1 !== \preg_match($regEx, $remoteUrl, $matches)) { 69 | throw new Exception\InvalidArgumentException(\sprintf( 70 | 'Unable to parse remote URL "%s".', 71 | $remoteUrl 72 | )); 73 | } 74 | 75 | return new self( 76 | $matches['owner'], 77 | $matches['name'] 78 | ); 79 | } 80 | 81 | /** 82 | * @throws Exception\InvalidArgumentException 83 | */ 84 | public static function fromString(string $string): self 85 | { 86 | if (1 !== \preg_match(self::stringRegex(), $string, $matches)) { 87 | throw new Exception\InvalidArgumentException(\sprintf( 88 | 'String "%s" does not appear to be a valid string.', 89 | $string 90 | )); 91 | } 92 | 93 | return new self( 94 | $matches['owner'], 95 | $matches['name'] 96 | ); 97 | } 98 | 99 | public function owner(): string 100 | { 101 | return $this->owner; 102 | } 103 | 104 | public function name(): string 105 | { 106 | return $this->name; 107 | } 108 | 109 | private static function ownerRegEx(bool $asPartial = false): string 110 | { 111 | $regEx = '(?P[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)'; 112 | 113 | if (true === $asPartial) { 114 | return $regEx; 115 | } 116 | 117 | return self::fullMatch($regEx); 118 | } 119 | 120 | private static function nameRegEx(bool $asPartial = false): string 121 | { 122 | $regEx = '(?P[a-zA-Z0-9-_.]+)'; 123 | 124 | if (true === $asPartial) { 125 | return $regEx; 126 | } 127 | 128 | return self::fullMatch($regEx); 129 | } 130 | 131 | private static function remoteUrlRegEx(): string 132 | { 133 | return self::fullMatch(\sprintf( 134 | '(?P(https:\/\/github\.com\/|git@github\.com:)%s\/%s\.git)', 135 | self::ownerRegEx(true), 136 | self::nameRegEx(true) 137 | )); 138 | } 139 | 140 | private static function stringRegex(): string 141 | { 142 | return self::fullMatch(\sprintf( 143 | '%s\/%s', 144 | self::ownerRegEx(true), 145 | self::nameRegEx(true) 146 | )); 147 | } 148 | 149 | private static function fullMatch(string $regEx): string 150 | { 151 | return \sprintf( 152 | '/^%s$/', 153 | $regEx 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Resource/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | login = $login; 23 | } 24 | 25 | public function login(): string 26 | { 27 | return $this->login; 28 | } 29 | 30 | public function htmlUrl(): string 31 | { 32 | return \sprintf( 33 | 'https://github.com/%s', 34 | $this->login 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Resource/UserInterface.php: -------------------------------------------------------------------------------- 1 | git = $git; 26 | } 27 | 28 | public function resolve(string ...$fromRemoteNames): Resource\RepositoryInterface 29 | { 30 | try { 31 | $remoteUrls = $this->git->remoteUrls(); 32 | } catch (Exception\RuntimeException $exception) { 33 | throw new Exception\RuntimeException('Unable to resolve repository using git meta data.'); 34 | } 35 | 36 | if (0 === \count($remoteUrls)) { 37 | throw new Exception\RuntimeException('Could not find any remote URLs.'); 38 | } 39 | 40 | if (0 < \count($fromRemoteNames)) { 41 | /** @var string[] $remoteUrls */ 42 | $remoteUrls = \array_replace( 43 | \array_flip($fromRemoteNames), 44 | \array_intersect_key( 45 | $remoteUrls, 46 | \array_flip($fromRemoteNames) 47 | ) 48 | ); 49 | } 50 | 51 | $repositories = \array_filter(\array_map(static function (string $remoteUrl): ?Resource\Repository { 52 | try { 53 | $repository = Resource\Repository::fromRemoteUrl($remoteUrl); 54 | } catch (Exception\InvalidArgumentException $exception) { 55 | return null; 56 | } 57 | 58 | return $repository; 59 | }, $remoteUrls)); 60 | 61 | if (0 === \count($repositories)) { 62 | if (0 < \count($fromRemoteNames)) { 63 | throw new Exception\RuntimeException(\sprintf( 64 | 'Could not find a valid remote URL for remotes "%s".', 65 | \implode('", "', $fromRemoteNames) 66 | )); 67 | } 68 | 69 | throw new Exception\RuntimeException('Could not find a valid remote URL.'); 70 | } 71 | 72 | return \array_shift($repositories); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Util/RepositoryResolverInterface.php: -------------------------------------------------------------------------------- 1 |