├── .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 | [](https://github.com/ergebnis/github-changelog/actions)
4 | [](https://github.com/ergebnis/github-changelog/actions)
5 | [](https://github.com/ergebnis/github-changelog/actions)
6 | [](https://github.com/ergebnis/github-changelog/actions)
7 |
8 | [](https://codecov.io/gh/ergebnis/github-changelog)
9 | [](https://shepherd.dev/github/ergebnis/github-changelog)
10 |
11 | [](https://packagist.org/packages/ergebnis/github-changelog)
12 | [](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 |