├── Procfile
├── README.md
├── .gitignore
├── .travis.yml
├── infection.json.dist
├── src
├── Github
│ ├── GenerateChangelog.php
│ ├── Api
│ │ ├── GraphQL
│ │ │ ├── RunQuery.php
│ │ │ ├── Query
│ │ │ │ ├── GetMilestoneChangelog
│ │ │ │ │ └── Response
│ │ │ │ │ │ ├── Author.php
│ │ │ │ │ │ ├── Label.php
│ │ │ │ │ │ ├── IssueOrPullRequest.php
│ │ │ │ │ │ └── Milestone.php
│ │ │ │ └── GetMilestoneChangelog.php
│ │ │ └── RunGraphQLQuery.php
│ │ ├── Hook
│ │ │ └── VerifyRequestSignature.php
│ │ └── V3
│ │ │ ├── CreateRelease.php
│ │ │ └── CreatePullRequest.php
│ ├── Value
│ │ └── RepositoryName.php
│ ├── CreateChangelogText.php
│ ├── JwageGenerateChangelog.php
│ └── Event
│ │ └── MilestoneClosedEvent.php
├── Gpg
│ └── SecretKeyId.php
├── Git
│ └── Value
│ │ ├── SemVerVersion.php
│ │ ├── BranchName.php
│ │ └── MergeTargetCandidateBranches.php
└── Environment
│ └── Variables.php
├── phpstan.neon.dist
├── phpunit.xml.dist
├── Makefile
├── phpcs.xml.dist
├── test
└── unit
│ ├── Github
│ ├── Api
│ │ ├── GraphQL
│ │ │ ├── Query
│ │ │ │ ├── GetMilestoneChangelog
│ │ │ │ │ └── Response
│ │ │ │ │ │ ├── AuthorTest.php
│ │ │ │ │ │ ├── LabelTest.php
│ │ │ │ │ │ ├── MilestoneTest.php
│ │ │ │ │ │ └── IssueOrPullRequestTest.php
│ │ │ │ └── GetMilestoneChangelogTest.php
│ │ │ └── RunGraphQLQueryTest.php
│ │ ├── Hook
│ │ │ └── VerifyRequestSignatureTest.php
│ │ └── V3
│ │ │ ├── CreateReleaseTest.php
│ │ │ └── CreatePullRequestTest.php
│ ├── Value
│ │ └── RepositoryNameTest.php
│ ├── JwageGenerateChangelogTest.php
│ ├── Event
│ │ └── MilestoneClosedEventTest.php
│ └── CreateChangelogTextTest.php
│ ├── Gpg
│ └── SecretKeyIdTest.php
│ ├── Environment
│ └── VariablesTest.php
│ └── Git
│ └── Value
│ ├── SemVerVersionTest.php
│ ├── BranchNameTest.php
│ └── MergeTargetCandidateBranchesTest.php
├── LICENSE
├── composer.json
├── psalm.xml.dist
├── feature
└── automated-releases.feature
└── public
└── index.php
/Procfile:
--------------------------------------------------------------------------------
1 | web: vendor/bin/heroku-php-apache2 public/
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Deprecated
2 |
3 | Please refer to https://github.com/laminas/automatic-releases instead
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | vendor
3 | .phpunit.result.cache
4 | infection.json
5 | infection.log
6 | phpcs.xml
7 | phpstan.neon
8 | phpunit.xml
9 | psalm.xml
10 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 |
3 | sudo: false
4 |
5 | php:
6 | - 7.3
7 |
8 | before_script:
9 | - composer install
10 |
11 | script:
12 | - make all
13 |
--------------------------------------------------------------------------------
/infection.json.dist:
--------------------------------------------------------------------------------
1 | {
2 | "timeout": 10,
3 | "source": {
4 | "directories": [
5 | "src"
6 | ]
7 | },
8 | "logs": {
9 | "text": "infection.log"
10 | },
11 | "mutators": {
12 | "@default": true
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Github/GenerateChangelog.php:
--------------------------------------------------------------------------------
1 | $variables
12 | *
13 | * @return mixed[]
14 | */
15 | public function __invoke(
16 | string $query,
17 | array $variables = []
18 | ) : array;
19 | }
20 |
--------------------------------------------------------------------------------
/phpstan.neon.dist:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 7
3 | paths:
4 | - public
5 | - src
6 | - test
7 | ignoreErrors:
8 | - '#Call to static method [a-zA-Z0-9\\_]+\(\) on an unknown class Tideways\\Profiler.#'
9 | - '#Class Tideways\\Profiler not found.#'
10 |
11 | includes:
12 | - vendor/phpstan/phpstan-beberlei-assert/extension.neon
13 | - vendor/phpstan/phpstan-phpunit/extension.neon
14 | - vendor/phpstan/phpstan-strict-rules/rules.neon
15 | - vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon
16 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 | ./test/unit
12 |
13 |
14 |
15 |
16 |
17 | src
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/Github/Api/Hook/VerifyRequestSignature.php:
--------------------------------------------------------------------------------
1 | getBody()->__toString(), $secret);
17 |
18 | Assert::that(hash_equals('sha1=' . $sha1, $request->getHeaderLine('X-Hub-Signature')))
19 | ->true();
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: *
2 |
3 | help:
4 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
5 |
6 | all: composer-validate static-analysis test no-leaks mutation-test cs ## run static analysis, tests, cs
7 | echo "all good"
8 |
9 | composer-validate:
10 | composer validate
11 |
12 | static-analysis:
13 | vendor/bin/phpstan analyse
14 | vendor/bin/psalm
15 |
16 | test:
17 | vendor/bin/phpunit
18 |
19 | no-leaks:
20 | vendor/bin/roave-no-leaks
21 |
22 | mutation-test:
23 | vendor/bin/infection --min-msi=58 --min-covered-msi=59
24 |
25 | cs:
26 | vendor/bin/phpcs
27 |
--------------------------------------------------------------------------------
/src/Gpg/SecretKeyId.php:
--------------------------------------------------------------------------------
1 | regex('/^[A-F0-9]+$/i');
22 |
23 | $instance = new self();
24 |
25 | $instance->id = $keyId;
26 |
27 | return $instance;
28 | }
29 |
30 | public function id() : string
31 | {
32 | return $this->id;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/phpcs.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ./public
16 | ./src
17 | ./test
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/AuthorTest.php:
--------------------------------------------------------------------------------
1 | 'Magoo',
16 | 'url' => 'http://example.com/',
17 | ]);
18 |
19 | self::assertSame('Magoo', $author->name());
20 | self::assertSame('http://example.com/', $author->url()->__toString());
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/LabelTest.php:
--------------------------------------------------------------------------------
1 | 'BC Break',
16 | 'color' => 'abcabc',
17 | 'url' => 'http://example.com/',
18 | ]);
19 |
20 | self::assertSame('BC Break', $label->name());
21 | self::assertSame('abcabc', $label->colour());
22 | self::assertSame('http://example.com/', $label->url()->__toString());
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/test/unit/Github/Value/RepositoryNameTest.php:
--------------------------------------------------------------------------------
1 | owner());
18 | self::assertSame('bar', $repositoryName->name());
19 | self::assertSame(
20 | 'https://token:x-oauth-basic@github.com/foo/bar.git',
21 | $repositoryName
22 | ->uriWithTokenAuthentication('token')
23 | ->__toString()
24 | );
25 |
26 | $repositoryName->assertMatchesOwner('foo');
27 |
28 | $this->expectException(AssertionFailedException::class);
29 |
30 | $repositoryName->assertMatchesOwner('potato');
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019 Doctrine Project
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7 | of the Software, and to permit persons to whom the Software is furnished to do
8 | so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/src/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/Author.php:
--------------------------------------------------------------------------------
1 | $payload */
24 | public static function fromPayload(array $payload) : self
25 | {
26 | Assert::that($payload)
27 | ->keyExists('login')
28 | ->keyExists('url');
29 |
30 | Assert::that($payload['login'])
31 | ->string()
32 | ->notEmpty();
33 |
34 | Assert::that($payload['url'])
35 | ->string()
36 | ->notEmpty();
37 |
38 | $instance = new self();
39 |
40 | $instance->name = $payload['login'];
41 | $instance->url = new Uri($payload['url']);
42 |
43 | return $instance;
44 | }
45 |
46 | public function name() : string
47 | {
48 | return $this->name;
49 | }
50 |
51 | public function url() : UriInterface
52 | {
53 | return $this->url;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/test/unit/Github/JwageGenerateChangelogTest.php:
--------------------------------------------------------------------------------
1 | setUser('doctrine')
21 | ->setRepository('repository-name')
22 | ->setMilestone('1.0.0');
23 |
24 | $output = new BufferedOutput();
25 |
26 | $changelogGenerator = $this->createMock(ChangelogGenerator::class);
27 |
28 | $changelogGenerator->expects(self::once())
29 | ->method('generate')
30 | ->with($config, $output);
31 |
32 | $repositoryName = RepositoryName::fromFullName('doctrine/repository-name');
33 | $semVerVersion = SemVerVersion::fromMilestoneName('1.0.0');
34 |
35 | (new JwageGenerateChangelog($changelogGenerator))
36 | ->__invoke($repositoryName, $semVerVersion);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/test/unit/Gpg/SecretKeyIdTest.php:
--------------------------------------------------------------------------------
1 | expectException(AssertionFailedException::class);
19 |
20 | SecretKeyId::fromBase16String($invalid);
21 | }
22 |
23 | /** @return array> */
24 | public function invalidKeys() : array
25 | {
26 | return [
27 | [''],
28 | ['foo'],
29 | ['abz'],
30 | ['FOO'],
31 | ['123z'],
32 | ];
33 | }
34 |
35 | /**
36 | * @dataProvider validKeys
37 | */
38 | public function testAcceptsValidKeyIds(string $valid) : void
39 | {
40 | self::assertSame(
41 | $valid,
42 | SecretKeyId::fromBase16String($valid)
43 | ->id()
44 | );
45 | }
46 |
47 | /** @return array> */
48 | public function validKeys() : array
49 | {
50 | return [
51 | ['123'],
52 | ['abc'],
53 | ['aaaaaaaaaaaaaaaaaaaa'],
54 | ['AAAAAAAAAAAAAAAAAAAA'],
55 | ];
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Github/Value/RepositoryName.php:
--------------------------------------------------------------------------------
1 | notEmpty()
29 | ->regex('~^[a-zA-Z0-9_\\.-]+/[a-zA-Z0-9_\\.-]+$~');
30 |
31 | $instance = new self();
32 |
33 | [$instance->owner, $instance->name] = explode('/', $fullName);
34 |
35 | return $instance;
36 | }
37 |
38 | public function assertMatchesOwner(string $owner) : void
39 | {
40 | Assert::that(strtolower($this->owner))
41 | ->same(strtolower($owner));
42 | }
43 |
44 | public function uriWithTokenAuthentication(string $token) : UriInterface
45 | {
46 | Assert::that($token)
47 | ->notEmpty();
48 |
49 | return new Uri('https://' . $token . ':x-oauth-basic@github.com/' . $this->owner . '/' . $this->name . '.git');
50 | }
51 |
52 | public function owner() : string
53 | {
54 | return $this->owner;
55 | }
56 |
57 | public function name() : string
58 | {
59 | return $this->name;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Git/Value/SemVerVersion.php:
--------------------------------------------------------------------------------
1 | notEmpty()
32 | ->regex('/^(v)?\\d+\\.\\d+\\.\\d+$/');
33 |
34 | preg_match('/(\\d+)\\.(\\d+)\\.(\\d+)/', $name, $matches);
35 |
36 | assert(is_array($matches));
37 |
38 | $instance = new self();
39 |
40 | [, $instance->major, $instance->minor, $instance->patch] = array_map('intval', $matches);
41 |
42 | return $instance;
43 | }
44 |
45 | public function fullReleaseName() : string
46 | {
47 | return $this->major . '.' . $this->minor . '.' . $this->patch;
48 | }
49 |
50 | public function major() : int
51 | {
52 | return $this->major;
53 | }
54 |
55 | public function minor() : int
56 | {
57 | return $this->minor;
58 | }
59 |
60 | public function targetReleaseBranchName() : BranchName
61 | {
62 | return BranchName::fromName($this->major . '.' . $this->minor . '.x');
63 | }
64 |
65 | public function isNewMinorRelease() : bool
66 | {
67 | return $this->patch === 0;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/Label.php:
--------------------------------------------------------------------------------
1 | $payload */
27 | public static function fromPayload(array $payload) : self
28 | {
29 | Assert::that($payload)
30 | ->keyExists('color')
31 | ->keyExists('name')
32 | ->keyExists('url');
33 |
34 | Assert::that($payload['color'])
35 | ->string()
36 | ->regex('/^[0-9a-f]{6}$/i');
37 |
38 | Assert::that($payload['name'])
39 | ->string()
40 | ->notEmpty();
41 |
42 | Assert::that($payload['url'])
43 | ->string()
44 | ->notEmpty();
45 |
46 | $instance = new self();
47 |
48 | $instance->colour = $payload['color'];
49 | $instance->name = $payload['name'];
50 | $instance->url = new Uri($payload['url']);
51 |
52 | return $instance;
53 | }
54 |
55 | public function colour() : string
56 | {
57 | return $this->colour;
58 | }
59 |
60 | public function name() : string
61 | {
62 | return $this->name;
63 | }
64 |
65 | public function url() : UriInterface
66 | {
67 | return $this->url;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Github/CreateChangelogText.php:
--------------------------------------------------------------------------------
1 | generateChangelog = $generateChangelog;
31 | }
32 |
33 | public function __invoke(
34 | Milestone $milestone,
35 | RepositoryName $repositoryName,
36 | SemVerVersion $semVerVersion
37 | ) : string {
38 | $replacements = [
39 | '%release%' => $this->markdownLink($milestone->title(), $milestone->url()),
40 | '%description%' => $milestone->description(),
41 | '%changelogText%' => $this->generateChangelog->__invoke(
42 | $repositoryName,
43 | $semVerVersion
44 | ),
45 | ];
46 |
47 | return str_replace(
48 | array_keys($replacements),
49 | $replacements,
50 | self::TEMPLATE
51 | );
52 | }
53 |
54 | private function markdownLink(string $text, UriInterface $uri) : string
55 | {
56 | return '[' . $text . '](' . $uri->__toString() . ')';
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "doctrine/automatic-releases",
3 | "type": "project",
4 | "description": "Application that eases release management in the Doctrine organisation through GitHub hooks",
5 | "license": "MIT",
6 | "autoload": {
7 | "psr-4": {
8 | "Doctrine\\AutomaticReleases\\": "src"
9 | }
10 | },
11 | "require": {
12 | "php": "^7.3",
13 | "beberlei/assert": "^3.2",
14 | "jwage/changelog-generator": "^1.2",
15 | "php-http/curl-client": "^2.1",
16 | "php-http/discovery": "^1.9",
17 | "php-http/httplug": "^2.2",
18 | "psr/http-client": "^1.0",
19 | "psr/http-message": "^1.0",
20 | "symfony/process": "^5.1",
21 | "thecodingmachine/safe": "^0.1.16",
22 | "zendframework/zend-diactoros": "^2.2",
23 | "ext-tideways": "@stable"
24 | },
25 | "autoload-dev": {
26 | "psr-4": {
27 | "Doctrine\\AutomaticReleases\\Test\\Unit\\": "test/unit"
28 | }
29 | },
30 | "require-dev": {
31 | "doctrine/coding-standard": "^6.0",
32 | "infection/infection": "^0.15.2",
33 | "phpstan/phpstan": "^0.11.19",
34 | "phpstan/phpstan-beberlei-assert": "^0.11.0",
35 | "phpstan/phpstan-phpunit": "^0.11.0",
36 | "phpstan/phpstan-strict-rules": "^0.11.0",
37 | "phpunit/phpunit": "^8.5",
38 | "psalm/plugin-phpunit": "^0.10.1",
39 | "roave/no-leaks": "^1.1",
40 | "squizlabs/php_codesniffer": "^3.5",
41 | "thecodingmachine/phpstan-safe-rule": "^0.1.3",
42 | "vimeo/psalm": "^3.10"
43 | },
44 | "config": {
45 | "sort-packages": true,
46 | "platform": {
47 | "php": "7.3.4",
48 | "ext-tideways": "1.0.0"
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Github/JwageGenerateChangelog.php:
--------------------------------------------------------------------------------
1 | changelogGenerator = $changelogGenerator;
28 | }
29 |
30 | public static function create(
31 | RequestFactoryInterface $messageFactory,
32 | ClientInterface $client
33 | ) : self {
34 | $issueClient = new IssueClient($messageFactory, $client);
35 | $issueFactory = new IssueFactory();
36 | $issueFetcher = new IssueFetcher($issueClient);
37 | $issueRepository = new IssueRepository($issueFetcher, $issueFactory);
38 | $issueGrouper = new IssueGrouper();
39 |
40 | return new self(new ChangelogGenerator($issueRepository, $issueGrouper));
41 | }
42 |
43 | public function __invoke(
44 | RepositoryName $repositoryName,
45 | SemVerVersion $semVerVersion
46 | ) : string {
47 | $config = (new ChangelogConfig())
48 | ->setUser($repositoryName->owner())
49 | ->setRepository($repositoryName->name())
50 | ->setMilestone($semVerVersion->fullReleaseName());
51 |
52 | $output = new BufferedOutput();
53 |
54 | $this->changelogGenerator->generate(
55 | $config,
56 | $output
57 | );
58 |
59 | return $output->fetch();
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Git/Value/BranchName.php:
--------------------------------------------------------------------------------
1 | notEmpty();
26 |
27 | $instance = new self();
28 |
29 | $instance->name = $name;
30 |
31 | return $instance;
32 | }
33 |
34 | public function name() : string
35 | {
36 | return $this->name;
37 | }
38 |
39 | public function isReleaseBranch() : bool
40 | {
41 | return preg_match('/^(v)?\d+\\.\d+(\\.x)?$/', $this->name) === 1;
42 | }
43 |
44 | public function isNextMajor() : bool
45 | {
46 | return $this->name === 'master';
47 | }
48 |
49 | /**
50 | * @return array
51 | *
52 | * @psalm-return array{0: int, 1: int}
53 | */
54 | public function majorAndMinor() : array
55 | {
56 | Assert::that($this->name)
57 | ->regex('/^(v)?\d+\\.\d+(\\.x)?$/');
58 |
59 | preg_match('/^(?:v)?(\d+)\\.(\d+)(?:\\.x)?$/', $this->name, $matches);
60 |
61 | assert(is_array($matches));
62 |
63 | [, $major, $minor] = array_map('intval', $matches);
64 |
65 | return [$major, $minor];
66 | }
67 |
68 | public function equals(self $other) : bool
69 | {
70 | return $other->name === $this->name;
71 | }
72 |
73 | public function isForVersion(SemVerVersion $version) : bool
74 | {
75 | return $this->majorAndMinor() === [$version->major(), $version->minor()];
76 | }
77 |
78 | public function isForNewerVersionThan(SemVerVersion $version) : bool
79 | {
80 | [$major, $minor] = $this->majorAndMinor();
81 | $comparedMajorVersion = $version->major();
82 |
83 | return $major > $comparedMajorVersion
84 | || ($comparedMajorVersion === $major && $minor > $version->minor());
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/psalm.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/Github/Api/GraphQL/Query/GetMilestoneChangelog.php:
--------------------------------------------------------------------------------
1 | runQuery = $runQuery;
73 | }
74 |
75 | public function __invoke(
76 | RepositoryName $repositoryName,
77 | int $milestoneNumber
78 | ) : Milestone {
79 | return Milestone::fromPayload($this->runQuery->__invoke(
80 | self::QUERY,
81 | [
82 | 'repositoryName' => $repositoryName->name(),
83 | 'owner' => $repositoryName->owner(),
84 | 'milestoneNumber' => $milestoneNumber,
85 | ]
86 | )['repository']['milestone']);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Environment/Variables.php:
--------------------------------------------------------------------------------
1 | githubOrganisation = self::getenv('GITHUB_ORGANISATION');
41 | $instance->githubToken = self::getenv('GITHUB_TOKEN');
42 | $instance->signingSecretKey = self::getenv('SIGNING_SECRET_KEY');
43 | $instance->githubHookSecret = self::getenv('GITHUB_HOOK_SECRET');
44 | $instance->gitAuthorName = self::getenv('GIT_AUTHOR_NAME');
45 | $instance->gitAuthorEmail = self::getenv('GIT_AUTHOR_EMAIL');
46 |
47 | return $instance;
48 | }
49 |
50 | private static function getenv(string $key) : string
51 | {
52 | Assert::that($key)
53 | ->notEmpty();
54 |
55 | $value = getenv($key);
56 |
57 | Assert::that($value)
58 | ->string()
59 | ->notEmpty();
60 |
61 | assert(is_string($value));
62 |
63 | return $value;
64 | }
65 |
66 | public function githubOrganisation() : string
67 | {
68 | return $this->githubOrganisation;
69 | }
70 |
71 | public function githubToken() : string
72 | {
73 | return $this->githubToken;
74 | }
75 |
76 | public function signingSecretKey() : string
77 | {
78 | return $this->signingSecretKey;
79 | }
80 |
81 | public function githubHookSecret() : string
82 | {
83 | return $this->githubHookSecret;
84 | }
85 |
86 | public function gitAuthorName() : string
87 | {
88 | return $this->gitAuthorName;
89 | }
90 |
91 | public function gitAuthorEmail() : string
92 | {
93 | return $this->gitAuthorEmail;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/Github/Api/GraphQL/RunGraphQLQuery.php:
--------------------------------------------------------------------------------
1 | notEmpty();
33 |
34 | $this->messageFactory = $messageFactory;
35 | $this->client = $client;
36 | $this->apiToken = $apiToken;
37 | }
38 |
39 | /** {@inheritDoc} */
40 | public function __invoke(
41 | string $query,
42 | array $variables = []
43 | ) : array {
44 | $request = $this->messageFactory
45 | ->createRequest('POST', self::ENDPOINT)
46 | ->withAddedHeader('Content-Type', 'application/json')
47 | ->withAddedHeader('User-Agent', 'Ocramius\'s minimal GraphQL client - stolen from Dunglas')
48 | ->withAddedHeader('Authorization', 'bearer ' . $this->apiToken);
49 |
50 | $request
51 | ->getBody()
52 | ->write(json_encode([
53 | 'query' => $query,
54 | 'variables' => $variables,
55 | ]));
56 |
57 | $response = $this->client->sendRequest($request);
58 |
59 | $responseBody = $response
60 | ->getBody()
61 | ->__toString();
62 |
63 | Assert::that($response->getStatusCode())
64 | ->same(200, $responseBody);
65 |
66 | Assert::that($responseBody)
67 | ->isJsonString();
68 |
69 | $responseData = json_decode($responseBody, true);
70 |
71 | Assert::that($responseData)
72 | ->keyNotExists('errors', $responseBody)
73 | ->keyExists('data', $responseBody);
74 |
75 | Assert::that($responseData['data'])
76 | ->isArray($responseBody);
77 |
78 | return $responseData['data'];
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/Github/Api/V3/CreateRelease.php:
--------------------------------------------------------------------------------
1 | notEmpty();
37 |
38 | $this->messageFactory = $messageFactory;
39 | $this->client = $client;
40 | $this->apiToken = $apiToken;
41 | }
42 |
43 | public function __invoke(
44 | RepositoryName $repository,
45 | SemVerVersion $version,
46 | string $releaseNotes
47 | ) : UriInterface {
48 | Assert::that($releaseNotes)
49 | ->notEmpty();
50 |
51 | $request = $this->messageFactory
52 | ->createRequest(
53 | 'POST',
54 | self::API_ROOT . 'repos/' . $repository->owner() . '/' . $repository->name() . '/releases'
55 | )
56 | ->withAddedHeader('Content-Type', 'application/json')
57 | ->withAddedHeader('User-Agent', 'Ocramius\'s minimal API V3 client')
58 | ->withAddedHeader('Authorization', 'bearer ' . $this->apiToken);
59 |
60 | $request
61 | ->getBody()
62 | ->write(json_encode([
63 | 'tag_name' => $version->fullReleaseName(),
64 | 'name' => $version->fullReleaseName(),
65 | 'body' => $releaseNotes,
66 | ]));
67 |
68 | $response = $this->client->sendRequest($request);
69 |
70 | $responseBody = $response
71 | ->getBody()
72 | ->__toString();
73 |
74 | Assert::that($response->getStatusCode())
75 | ->between(200, 299, $responseBody);
76 |
77 | Assert::that($responseBody)
78 | ->isJsonString();
79 |
80 | $responseData = json_decode($responseBody, true);
81 |
82 | Assert::that($responseData)
83 | ->keyExists('html_url', $responseBody);
84 |
85 | return new Uri($responseData['html_url']);
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/Github/Api/V3/CreatePullRequest.php:
--------------------------------------------------------------------------------
1 | notEmpty();
35 |
36 | $this->messageFactory = $messageFactory;
37 | $this->client = $client;
38 | $this->apiToken = $apiToken;
39 | }
40 |
41 | public function __invoke(
42 | RepositoryName $repository,
43 | BranchName $head,
44 | BranchName $target,
45 | string $title,
46 | string $body
47 | ) : void {
48 | Assert::that($title)
49 | ->notEmpty();
50 |
51 | $request = $this->messageFactory
52 | ->createRequest(
53 | 'POST',
54 | self::API_ROOT . 'repos/' . $repository->owner() . '/' . $repository->name() . '/pulls'
55 | )
56 | ->withAddedHeader('Content-Type', 'application/json')
57 | ->withAddedHeader('User-Agent', 'Ocramius\'s minimal API V3 client')
58 | ->withAddedHeader('Authorization', 'bearer ' . $this->apiToken);
59 |
60 | $request
61 | ->getBody()
62 | ->write(json_encode([
63 | 'title' => $title,
64 | 'head' => $head->name(),
65 | 'base' => $target->name(),
66 | 'body' => $body,
67 | 'maintainer_can_modify' => true,
68 | 'draft' => false,
69 | ]));
70 |
71 | $response = $this->client->sendRequest($request);
72 |
73 | $responseBody = $response
74 | ->getBody()
75 | ->__toString();
76 |
77 | Assert::that($response->getStatusCode())
78 | ->between(200, 299, $responseBody);
79 |
80 | Assert::that($responseBody)
81 | ->isJsonString();
82 |
83 | $responseData = json_decode($responseBody, true);
84 |
85 | Assert::that($responseData)
86 | ->keyExists('url', $responseBody);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Github/Event/MilestoneClosedEvent.php:
--------------------------------------------------------------------------------
1 | getHeaderLine('X-Github-Event') !== 'milestone') {
34 | return false;
35 | }
36 |
37 | $body = $request->getParsedBody();
38 |
39 | assert(is_array($body));
40 |
41 | if (! array_key_exists('payload', $body)) {
42 | return false;
43 | }
44 |
45 | $event = json_decode($body['payload'], true);
46 |
47 | return $event['action'] === 'closed';
48 | }
49 |
50 | public static function fromEventJson(string $json) : self
51 | {
52 | $event = json_decode($json, true);
53 |
54 | Assert::that($event)
55 | ->keyExists('milestone')
56 | ->keyExists('repository')
57 | ->keyExists('action');
58 |
59 | Assert::that($event['action'])
60 | ->same('closed');
61 |
62 | Assert::that($event['milestone'])
63 | ->keyExists('title')
64 | ->keyExists('number');
65 |
66 | Assert::that($event['milestone']['title'])
67 | ->string()
68 | ->notEmpty();
69 |
70 | Assert::that($event['milestone']['number'])
71 | ->integer()
72 | ->greaterThan(0);
73 |
74 | Assert::that($event['repository'])
75 | ->keyExists('full_name');
76 |
77 | $instance = new self();
78 |
79 | $instance->repository = RepositoryName::fromFullName($event['repository']['full_name']);
80 | $instance->milestoneNumber = $event['milestone']['number'];
81 | $instance->version = SemVerVersion::fromMilestoneName($event['milestone']['title']);
82 |
83 | return $instance;
84 | }
85 |
86 | public function repository() : RepositoryName
87 | {
88 | return $this->repository;
89 | }
90 |
91 | public function milestoneNumber() : int
92 | {
93 | return $this->milestoneNumber;
94 | }
95 |
96 | public function version() : SemVerVersion
97 | {
98 | return $this->version;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/test/unit/Environment/VariablesTest.php:
--------------------------------------------------------------------------------
1 | */
29 | private $originalValues = [];
30 |
31 | protected function setUp() : void
32 | {
33 | parent::setUp();
34 |
35 | $this->originalValues = array_combine(
36 | self::RESET_ENVIRONMENT_VARIABLES,
37 | // array_map('getenv', self::RESET_ENVIRONMENT_VARIABLES)
38 | array_map(static function (string $key) {
39 | return getenv($key);
40 | }, self::RESET_ENVIRONMENT_VARIABLES)
41 | );
42 | }
43 |
44 | protected function tearDown() : void
45 | {
46 | array_walk($this->originalValues, static function ($value, string $key) : void {
47 | if ($value === false) {
48 | putenv($key . '=');
49 |
50 | return;
51 | }
52 |
53 | putenv($key . '=' . $value);
54 | });
55 |
56 | parent::tearDown();
57 | }
58 |
59 | public function testReadsEnvironmentVariables() : void
60 | {
61 | $githubHookSecret = uniqid('githubHookSecret', true);
62 | $signingSecretKey = uniqid('signingSecretKey', true);
63 | $githubToken = uniqid('githubToken', true);
64 | $githubOrganisation = uniqid('githubOrganisation', true);
65 | $gitAuthorName = uniqid('gitAuthorName', true);
66 | $gitAuthorEmail = uniqid('gitAuthorEmail', true);
67 |
68 | putenv('GITHUB_HOOK_SECRET=' . $githubHookSecret);
69 | putenv('GITHUB_TOKEN=' . $githubToken);
70 | putenv('SIGNING_SECRET_KEY=' . $signingSecretKey);
71 | putenv('GITHUB_ORGANISATION=' . $githubOrganisation);
72 | putenv('GIT_AUTHOR_NAME=' . $gitAuthorName);
73 | putenv('GIT_AUTHOR_EMAIL=' . $gitAuthorEmail);
74 |
75 | $variables = Variables::fromEnvironment();
76 |
77 | self::assertSame($githubHookSecret, $variables->githubHookSecret());
78 | self::assertSame($signingSecretKey, $variables->signingSecretKey());
79 | self::assertSame($githubToken, $variables->githubToken());
80 | self::assertSame($githubOrganisation, $variables->githubOrganisation());
81 | self::assertSame($gitAuthorName, $variables->gitAuthorName());
82 | self::assertSame($gitAuthorEmail, $variables->gitAuthorEmail());
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/GraphQL/RunGraphQLQueryTest.php:
--------------------------------------------------------------------------------
1 | httpClient = $this->createMock(ClientInterface::class);
36 | $this->messageFactory = $this->createMock(RequestFactoryInterface::class);
37 | $this->apiToken = uniqid('apiToken', true);
38 | $this->runQuery = new RunGraphQLQuery(
39 | $this->messageFactory,
40 | $this->httpClient,
41 | $this->apiToken
42 | );
43 |
44 | $this
45 | ->messageFactory
46 | ->expects(self::any())
47 | ->method('createRequest')
48 | ->with('POST', 'https://api.github.com/graphql')
49 | ->willReturn(new Request('https://the-domain.com/the-path'));
50 | }
51 |
52 | public function testSuccessfulRequest() : void
53 | {
54 | $validResponse = new Response();
55 |
56 | $validResponse->getBody()->write(<<<'JSON'
57 | {
58 | "data": {"foo": "bar"}
59 | }
60 | JSON
61 | );
62 |
63 | $this
64 | ->httpClient
65 | ->expects(self::once())
66 | ->method('sendRequest')
67 | ->with(self::callback(function (RequestInterface $request) : bool {
68 | self::assertSame(
69 | [
70 | 'Host' => ['the-domain.com'],
71 | 'Content-Type' => ['application/json'],
72 | 'User-Agent' => ['Ocramius\'s minimal GraphQL client - stolen from Dunglas'],
73 | 'Authorization' => ['bearer ' . $this->apiToken],
74 | ],
75 | $request->getHeaders()
76 | );
77 |
78 | self::assertSame(
79 | '{"query":"the-query","variables":{"a":"b"}}',
80 | $request->getBody()->__toString()
81 | );
82 |
83 | return true;
84 | }))
85 | ->willReturn($validResponse);
86 |
87 | self::assertSame(
88 | ['foo' => 'bar'],
89 | $this->runQuery->__invoke('the-query', ['a' => 'b'])
90 | );
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/Git/Value/MergeTargetCandidateBranches.php:
--------------------------------------------------------------------------------
1 | isReleaseBranch()
28 | || $branch->isNextMajor();
29 | });
30 |
31 | Assert::that($mergeTargetBranches)
32 | ->notEmpty();
33 |
34 | usort($mergeTargetBranches, static function (BranchName $a, BranchName $b) : int {
35 | if ($a->isNextMajor()) {
36 | return 1;
37 | }
38 |
39 | if ($b->isNextMajor()) {
40 | return -1;
41 | }
42 |
43 | return $a->majorAndMinor() <=> $b->majorAndMinor();
44 | });
45 |
46 | $instance = new self();
47 |
48 | $instance->sortedBranches = $mergeTargetBranches;
49 |
50 | return $instance;
51 | }
52 |
53 | public function targetBranchFor(SemVerVersion $version) : ?BranchName
54 | {
55 | foreach ($this->sortedBranches as $branch) {
56 | if ($branch->isNextMajor()) {
57 | if (! $version->isNewMinorRelease()) {
58 | return null;
59 | }
60 |
61 | return $branch;
62 | }
63 |
64 | if ($branch->isForNewerVersionThan($version)) {
65 | return null;
66 | }
67 |
68 | if ($branch->isForVersion($version)) {
69 | return $branch;
70 | }
71 | }
72 |
73 | return null;
74 | }
75 |
76 | public function branchToMergeUp(SemVerVersion $version) : ?BranchName
77 | {
78 | $targetBranch = $this->targetBranchFor($version);
79 |
80 | if ($targetBranch === null) {
81 | // There's no branch where we can merge this, so we can't merge up either
82 | return null;
83 | }
84 |
85 | $lastBranch = end($this->sortedBranches);
86 |
87 | assert($lastBranch instanceof BranchName);
88 |
89 | $targetBranchKey = array_search($targetBranch, $this->sortedBranches, true);
90 |
91 | $branch = is_int($targetBranchKey)
92 | ? ($this->sortedBranches[$targetBranchKey + 1] ?? $lastBranch)
93 | : $lastBranch;
94 |
95 | // If the target branch and the merge-up branch are the same, no merge-up is needed
96 | return $branch === $targetBranch
97 | ? null
98 | : $branch;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/Hook/VerifyRequestSignatureTest.php:
--------------------------------------------------------------------------------
1 | createMock(RequestInterface::class);
21 | $stream = $this->createMock(StreamInterface::class);
22 |
23 | $request
24 | ->expects(self::any())
25 | ->method('getHeaderLine')
26 | ->with('X-Hub-Signature')
27 | ->willReturn($signatureHeader);
28 |
29 | $stream
30 | ->expects(self::any())
31 | ->method('__toString')
32 | ->willReturn($body);
33 |
34 | $request
35 | ->expects(self::any())
36 | ->method('getBody')
37 | ->willReturn($stream);
38 |
39 | (new VerifyRequestSignature())
40 | ->__invoke($request, $secret);
41 |
42 | // assertion is silent if it doesn't fail
43 | $this->addToAssertionCount(1);
44 | }
45 |
46 | /** @return array> */
47 | public function validSignatures() : array
48 | {
49 | return [
50 | ['the_secret', 'the_body', 'sha1=578824bd817c673685995ff825fe9efb38caf1f5'],
51 | ['another_secret', 'the_body', 'sha1=dd19ebefd33527d1011205cf1eb87ae7306e2f03'],
52 | ];
53 | }
54 |
55 | /**
56 | * @dataProvider invalidSignatures
57 | */
58 | public function testInvalidSignature(string $secret, string $body, string $signatureHeader) : void
59 | {
60 | $request = $this->createMock(RequestInterface::class);
61 | $stream = $this->createMock(StreamInterface::class);
62 |
63 | $request
64 | ->expects(self::any())
65 | ->method('getHeaderLine')
66 | ->with('X-Hub-Signature')
67 | ->willReturn($signatureHeader);
68 |
69 | $stream
70 | ->expects(self::any())
71 | ->method('__toString')
72 | ->willReturn($body);
73 |
74 | $request
75 | ->expects(self::any())
76 | ->method('getBody')
77 | ->willReturn($stream);
78 |
79 | $validator = new VerifyRequestSignature();
80 |
81 | $this->expectException(AssertionFailedException::class);
82 |
83 | $validator->__invoke($request, $secret);
84 | }
85 |
86 | /** @return array> */
87 | public function invalidSignatures() : array
88 | {
89 | return [
90 | ['the_secret', 'the_body', 'sha1=578824bd817c673685995ff825fe9efb38caf1f6'],
91 | ['another_secret', 'the_body', 'sha1=578824bd817c673685995ff825fe9efb38caf1f6'],
92 | ];
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/IssueOrPullRequest.php:
--------------------------------------------------------------------------------
1 | */
25 | private $labels;
26 |
27 | /** @var bool */
28 | private $closed;
29 |
30 | /** @var UriInterface */
31 | private $url;
32 |
33 | private function __construct()
34 | {
35 | }
36 |
37 | /** @param array $payload */
38 | public static function fromPayload(array $payload) : self
39 | {
40 | Assert::that($payload)
41 | ->keyExists('number')
42 | ->keyExists('title')
43 | ->keyExists('author')
44 | ->keyExists('url')
45 | ->keyExists('closed')
46 | ->keyExists('labels');
47 |
48 | Assert::that($payload['number'])
49 | ->integer()
50 | ->greaterThan(0);
51 |
52 | Assert::that($payload['title'])
53 | ->string()
54 | ->notEmpty();
55 |
56 | Assert::that($payload['author'])
57 | ->isArray();
58 |
59 | Assert::that($payload['labels'])
60 | ->isArray()
61 | ->keyExists('nodes');
62 |
63 | Assert::that($payload['labels']['nodes'])
64 | ->isArray();
65 |
66 | Assert::that($payload['url'])
67 | ->string()
68 | ->notEmpty();
69 |
70 | Assert::that($payload['closed'])
71 | ->boolean();
72 |
73 | $instance = new self();
74 |
75 | $instance->number = $payload['number'];
76 | $instance->title = $payload['title'];
77 | $instance->author = Author::fromPayload($payload['author']);
78 | $instance->labels = array_values(array_map([Label::class, 'fromPayload'], $payload['labels']['nodes']));
79 | $instance->url = new Uri($payload['url']);
80 | $instance->closed = isset($payload['merged'])
81 | ? (bool) $payload['merged'] || $payload['closed']
82 | : $payload['closed'];
83 |
84 | return $instance;
85 | }
86 |
87 | public function number() : int
88 | {
89 | return $this->number;
90 | }
91 |
92 | public function title() : string
93 | {
94 | return $this->title;
95 | }
96 |
97 | public function author() : Author
98 | {
99 | return $this->author;
100 | }
101 |
102 | /** @return array */
103 | public function labels() : array
104 | {
105 | return $this->labels;
106 | }
107 |
108 | public function closed() : bool
109 | {
110 | return $this->closed;
111 | }
112 |
113 | public function url() : UriInterface
114 | {
115 | return $this->url;
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/feature/automated-releases.feature:
--------------------------------------------------------------------------------
1 | @manually-tested
2 | Feature: Automated releases
3 |
4 | Scenario: If no major release branch exists, the tool should not create a new major release
5 | Given following existing branches:
6 | | name |
7 | | 1.0.x |
8 | And following open milestones:
9 | | name |
10 | | 2.0.0 |
11 | When I close milestone "2.0.0"
12 | Then the tool should have halted with an error
13 |
14 | Scenario: If no major release branch exists, the tool should not create a new minor release
15 | Given following existing branches:
16 | | name |
17 | | 1.0.x |
18 | And following open milestones:
19 | | name |
20 | | 1.1.0 |
21 | When I close milestone "1.1.0"
22 | Then the tool should have halted with an error
23 |
24 | Scenario: If a major release branch exists, the tool creates a major release from there
25 | Given following existing branches:
26 | | name |
27 | | 1.0.x |
28 | | master |
29 | And following open milestones:
30 | | name |
31 | | 2.0.0 |
32 | When I close milestone "2.0.0"
33 | Then tag "2.0.0" should have been created on branch "master"
34 | And branch "2.0.x" should have been created from "master"
35 |
36 | Scenario: If a major release branch exists, the tool creates a new minor release from there
37 | Given following existing branches:
38 | | name |
39 | | 1.0.x |
40 | | master |
41 | And following open milestones:
42 | | name |
43 | | 1.1.0 |
44 | When I close milestone "1.1.0"
45 | Then tag "1.1.0" should have been created on branch "master"
46 | And branch "1.1.x" should have been created from "master"
47 |
48 | Scenario: If a minor release branch exists, the tool creates a new minor release from there
49 | Given following existing branches:
50 | | name |
51 | | 1.1.x |
52 | | master |
53 | And following open milestones:
54 | | name |
55 | | 1.1.0 |
56 | When I close milestone "1.1.0"
57 | Then tag "1.1.0" should have been created on branch "1.1.x"
58 | And a new pull request from branch "1.1.x" to "master" should have been created
59 |
60 | Scenario: If a minor release branch exists, the tool creates a new patch release from there
61 | Given following existing branches:
62 | | name |
63 | | 1.1.x |
64 | | 1.2.x |
65 | | master |
66 | And following open milestones:
67 | | name |
68 | | 1.1.1 |
69 | When I close milestone "1.1.1"
70 | Then tag "1.1.1" should have been created on branch "1.1.x"
71 | And a new pull request from branch "1.1.x" to "1.2.x" should have been created
72 |
73 | Scenario: If a minor release branch doesn't exist, the tool refuses to create it if a newer one exists
74 | Given following existing branches:
75 | | name |
76 | | 1.2.x |
77 | | master |
78 | And following open milestones:
79 | | name |
80 | | 1.1.0 |
81 | When I close milestone "1.1.0"
82 | Then the tool should have halted with an error
83 |
84 | Scenario: If a minor release branch doesn't exist, the tool refuses to create a patch release
85 | Given following existing branches:
86 | | name |
87 | | master |
88 | And following open milestones:
89 | | name |
90 | | 1.1.1 |
91 | When I close milestone "1.1.1"
92 | Then the tool should have halted with an error
93 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/V3/CreateReleaseTest.php:
--------------------------------------------------------------------------------
1 | httpClient = $this->createMock(ClientInterface::class);
38 | $this->messageFactory = $this->createMock(RequestFactoryInterface::class);
39 | $this->apiToken = uniqid('apiToken', true);
40 | $this->createRelease = new CreateRelease(
41 | $this->messageFactory,
42 | $this->httpClient,
43 | $this->apiToken
44 | );
45 | }
46 |
47 | public function testSuccessfulRequest() : void
48 | {
49 | $this
50 | ->messageFactory
51 | ->expects(self::any())
52 | ->method('createRequest')
53 | ->with('POST', 'https://api.github.com/repos/foo/bar/releases')
54 | ->willReturn(new Request('https://the-domain.com/the-path'));
55 |
56 | $validResponse = new Response();
57 |
58 | $validResponse->getBody()->write(<<<'JSON'
59 | {
60 | "html_url": "http://another-domain.com/the-pr"
61 | }
62 | JSON
63 | );
64 |
65 | $this
66 | ->httpClient
67 | ->expects(self::once())
68 | ->method('sendRequest')
69 | ->with(self::callback(function (RequestInterface $request) : bool {
70 | self::assertSame(
71 | [
72 | 'Host' => ['the-domain.com'],
73 | 'Content-Type' => ['application/json'],
74 | 'User-Agent' => ['Ocramius\'s minimal API V3 client'],
75 | 'Authorization' => ['bearer ' . $this->apiToken],
76 | ],
77 | $request->getHeaders()
78 | );
79 |
80 | self::assertJsonStringEqualsJsonString(
81 | <<<'JSON'
82 | {
83 | "tag_name": "1.2.3",
84 | "name": "1.2.3",
85 | "body": "the-body"
86 | }
87 | JSON
88 | ,
89 | $request->getBody()->__toString()
90 | );
91 |
92 | return true;
93 | }))
94 | ->willReturn($validResponse);
95 |
96 | self::assertEquals(
97 | 'http://another-domain.com/the-pr',
98 | $this->createRelease->__invoke(
99 | RepositoryName::fromFullName('foo/bar'),
100 | SemVerVersion::fromMilestoneName('1.2.3'),
101 | 'the-body'
102 | )
103 | );
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/V3/CreatePullRequestTest.php:
--------------------------------------------------------------------------------
1 | httpClient = $this->createMock(ClientInterface::class);
38 | $this->messageFactory = $this->createMock(RequestFactoryInterface::class);
39 | $this->apiToken = uniqid('apiToken', true);
40 | $this->createPullRequest = new CreatePullRequest(
41 | $this->messageFactory,
42 | $this->httpClient,
43 | $this->apiToken
44 | );
45 | }
46 |
47 | public function testSuccessfulRequest() : void
48 | {
49 | $this
50 | ->messageFactory
51 | ->expects(self::any())
52 | ->method('createRequest')
53 | ->with('POST', 'https://api.github.com/repos/foo/bar/pulls')
54 | ->willReturn(new Request('https://the-domain.com/the-path'));
55 |
56 | $validResponse = new Response();
57 |
58 | $validResponse->getBody()->write(<<<'JSON'
59 | {
60 | "url": "http://another-domain.com/the-pr"
61 | }
62 | JSON
63 | );
64 | $this
65 | ->httpClient
66 | ->expects(self::once())
67 | ->method('sendRequest')
68 | ->with(self::callback(function (RequestInterface $request) : bool {
69 | self::assertSame(
70 | [
71 | 'Host' => ['the-domain.com'],
72 | 'Content-Type' => ['application/json'],
73 | 'User-Agent' => ['Ocramius\'s minimal API V3 client'],
74 | 'Authorization' => ['bearer ' . $this->apiToken],
75 | ],
76 | $request->getHeaders()
77 | );
78 |
79 | self::assertJsonStringEqualsJsonString(
80 | <<<'JSON'
81 | {
82 | "title": "the-title",
83 | "head": "the/source-branch",
84 | "base": "the/target-branch",
85 | "body": "the-body",
86 | "maintainer_can_modify": true,
87 | "draft": false
88 | }
89 | JSON
90 | ,
91 | $request->getBody()->__toString()
92 | );
93 |
94 | return true;
95 | }))
96 | ->willReturn($validResponse);
97 |
98 | $this->createPullRequest->__invoke(
99 | RepositoryName::fromFullName('foo/bar'),
100 | BranchName::fromName('the/source-branch'),
101 | BranchName::fromName('the/target-branch'),
102 | 'the-title',
103 | 'the-body'
104 | );
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/test/unit/Github/Event/MilestoneClosedEventTest.php:
--------------------------------------------------------------------------------
1 | createMock(ServerRequestInterface::class);
18 | $request
19 | ->expects(self::any())
20 | ->method('getParsedBody')
21 | ->willReturn(['payload' => '{"action":"closed"}']);
22 |
23 | $request
24 | ->expects(self::any())
25 | ->method('getHeaderLine')
26 | ->with('X-Github-Event')
27 | ->willReturn('milestone');
28 |
29 | self::assertTrue(MilestoneClosedEvent::appliesToRequest($request));
30 | }
31 |
32 | public function testWillNotApplyWithInvalidEventTypeHeaderAndBody() : void
33 | {
34 | $request = $this->createMock(ServerRequestInterface::class);
35 | $request
36 | ->expects(self::any())
37 | ->method('getParsedBody')
38 | ->willReturn(['payload' => '{"action":"closed"}']);
39 |
40 | $request
41 | ->expects(self::any())
42 | ->method('getHeaderLine')
43 | ->with('X-Github-Event')
44 | ->willReturn('potato');
45 |
46 | self::assertFalse(MilestoneClosedEvent::appliesToRequest($request));
47 | }
48 |
49 | public function testWillNotApplyWithValidEventTypeHeaderAndInvalidBody() : void
50 | {
51 | $request = $this->createMock(ServerRequestInterface::class);
52 | $request
53 | ->expects(self::any())
54 | ->method('getParsedBody')
55 | ->willReturn(['payload' => '{"action":"potato"}']);
56 |
57 | $request
58 | ->expects(self::any())
59 | ->method('getHeaderLine')
60 | ->with('X-Github-Event')
61 | ->willReturn('milestone');
62 |
63 | self::assertFalse(MilestoneClosedEvent::appliesToRequest($request));
64 | }
65 |
66 | public function testWillNotApplyWithValidEventTypeHeaderAndNoPayload() : void
67 | {
68 | $request = $this->createMock(ServerRequestInterface::class);
69 | $request
70 | ->expects(self::any())
71 | ->method('getParsedBody')
72 | ->willReturn([]);
73 |
74 | $request
75 | ->expects(self::any())
76 | ->method('getHeaderLine')
77 | ->with('X-Github-Event')
78 | ->willReturn('milestone');
79 |
80 | self::assertFalse(MilestoneClosedEvent::appliesToRequest($request));
81 | }
82 |
83 | public function testFromEventJson() : void
84 | {
85 | $json = <<<'JSON'
86 | {
87 | "milestone": {
88 | "title": "1.2.3",
89 | "number": 123
90 | },
91 | "repository": {
92 | "full_name": "foo/bar"
93 | },
94 | "action": "closed"
95 | }
96 | JSON;
97 |
98 | $milestone = MilestoneClosedEvent::fromEventJson($json);
99 |
100 | self::assertSame(123, $milestone->milestoneNumber());
101 | self::assertEquals(SemVerVersion::fromMilestoneName('1.2.3'), $milestone->version());
102 | self::assertEquals(RepositoryName::fromFullName('foo/bar'), $milestone->repository());
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/test/unit/Git/Value/SemVerVersionTest.php:
--------------------------------------------------------------------------------
1 | major());
26 | self::assertSame($expectedMinor, $version->minor());
27 | self::assertSame($expectedVersionName, $version->fullReleaseName());
28 | }
29 |
30 | /**
31 | * @return array>
32 | *
33 | * @psalm-return array
34 | */
35 | public function detectableReleases() : array
36 | {
37 | return [
38 | ['1.2.3', 1, 2, '1.2.3'],
39 | ['v1.2.3', 1, 2, '1.2.3'],
40 | ['v4.3.2', 4, 3, '4.3.2'],
41 | ['v44.33.22', 44, 33, '44.33.22'],
42 | ];
43 | }
44 |
45 | /**
46 | * @dataProvider invalidReleases
47 | */
48 | public function testRejectsInvalidReleaseStrings(string $invalid) : void
49 | {
50 | $this->expectException(AssertionFailedException::class);
51 |
52 | SemVerVersion::fromMilestoneName($invalid);
53 | }
54 |
55 | /** @return array> */
56 | public function invalidReleases() : array
57 | {
58 | return [
59 | ['1.2.3.4'],
60 | ['v1.2.3.4'],
61 | ['x1.2.3'],
62 | ['1.2.3 '],
63 | [' 1.2.3'],
64 | [''],
65 | ['potato'],
66 | ['1.2.'],
67 | ['1.2'],
68 | ];
69 | }
70 |
71 | /**
72 | * @dataProvider releaseBranchNames
73 | */
74 | public function testReleaseBranchNames(string $milestoneName, string $expectedTargetBranch) : void
75 | {
76 | self::assertEquals(
77 | BranchName::fromName($expectedTargetBranch),
78 | SemVerVersion::fromMilestoneName($milestoneName)
79 | ->targetReleaseBranchName()
80 | );
81 | }
82 |
83 | /** @return array> */
84 | public function releaseBranchNames() : array
85 | {
86 | return [
87 | ['1.2.3', '1.2.x'],
88 | ['2.0.0', '2.0.x'],
89 | ['99.99.99', '99.99.x'],
90 | ];
91 | }
92 |
93 | /**
94 | * @dataProvider newMinorReleasesProvider
95 | */
96 | public function testIsNewMinorRelease(string $milestoneName, bool $expected) : void
97 | {
98 | self::assertSame(
99 | $expected,
100 | SemVerVersion::fromMilestoneName($milestoneName)
101 | ->isNewMinorRelease()
102 | );
103 | }
104 |
105 | /**
106 | * @return array>
107 | *
108 | * @psalm-return array
109 | */
110 | public function newMinorReleasesProvider() : array
111 | {
112 | return [
113 | ['1.0.0', true],
114 | ['1.1.0', true],
115 | ['1.1.1', false],
116 | ['1.1.2', false],
117 | ['1.1.90', false],
118 | ['0.9.0', true],
119 | ];
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/GraphQL/Query/GetMilestoneChangelogTest.php:
--------------------------------------------------------------------------------
1 | runQuery = $this->createMock(RunQuery::class);
26 | $this->query = new GetMilestoneChangelog($this->runQuery);
27 | }
28 |
29 | public function testRetrievesMilestone() : void
30 | {
31 | $this->runQuery
32 | ->expects(self::once())
33 | ->method('__invoke')
34 | ->with(
35 | self::anything(),
36 | [
37 | 'repositoryName' => 'bar',
38 | 'owner' => 'foo',
39 | 'milestoneNumber' => 123,
40 | ]
41 | )
42 | ->willReturn([
43 | 'repository' => [
44 | 'milestone' => [
45 | 'number' => 123,
46 | 'closed' => true,
47 | 'title' => 'The title',
48 | 'description' => 'The description',
49 | 'issues' => [
50 | 'nodes' => [
51 | [
52 | 'number' => 456,
53 | 'title' => 'Issue',
54 | 'author' => [
55 | 'login' => 'Magoo',
56 | 'url' => 'http://example.com/author',
57 | ],
58 | 'url' => 'http://example.com/issue',
59 | 'closed' => true,
60 | 'labels' => [
61 | 'nodes' => [],
62 | ],
63 | ],
64 | ],
65 | ],
66 | 'pullRequests' => [
67 | 'nodes' => [
68 | [
69 | 'number' => 789,
70 | 'title' => 'PR',
71 | 'author' => [
72 | 'login' => 'Magoo',
73 | 'url' => 'http://example.com/author',
74 | ],
75 | 'url' => 'http://example.com/issue',
76 | 'merged' => true,
77 | 'closed' => false,
78 | 'labels' => [
79 | 'nodes' => [],
80 | ],
81 | ],
82 | ],
83 | ],
84 | 'url' => 'http://example.com/milestone',
85 | ],
86 | ],
87 | ]);
88 |
89 | $this->query->__invoke(
90 | RepositoryName::fromFullName('foo/bar'),
91 | 123
92 | );
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/MilestoneTest.php:
--------------------------------------------------------------------------------
1 | 123,
17 | 'closed' => true,
18 | 'title' => 'The title',
19 | 'description' => 'The description',
20 | 'issues' => [
21 | 'nodes' => [
22 | [
23 | 'number' => 456,
24 | 'title' => 'Issue',
25 | 'author' => [
26 | 'login' => 'Magoo',
27 | 'url' => 'http://example.com/author',
28 | ],
29 | 'url' => 'http://example.com/issue',
30 | 'closed' => true,
31 | 'labels' => [
32 | 'nodes' => [],
33 | ],
34 | ],
35 | ],
36 | ],
37 | 'pullRequests' => [
38 | 'nodes' => [
39 | [
40 | 'number' => 789,
41 | 'title' => 'PR',
42 | 'author' => [
43 | 'login' => 'Magoo',
44 | 'url' => 'http://example.com/author',
45 | ],
46 | 'url' => 'http://example.com/issue',
47 | 'merged' => true,
48 | 'closed' => false,
49 | 'labels' => [
50 | 'nodes' => [],
51 | ],
52 | ],
53 | ],
54 | ],
55 | 'url' => 'http://example.com/milestone',
56 | ]);
57 |
58 | self::assertEquals(
59 | [
60 | IssueOrPullRequest::fromPayload([
61 | 'number' => 456,
62 | 'title' => 'Issue',
63 | 'author' => [
64 | 'login' => 'Magoo',
65 | 'url' => 'http://example.com/author',
66 | ],
67 | 'url' => 'http://example.com/issue',
68 | 'closed' => true,
69 | 'labels' => [
70 | 'nodes' => [],
71 | ],
72 | ]),
73 | IssueOrPullRequest::fromPayload([
74 | 'number' => 789,
75 | 'title' => 'PR',
76 | 'author' => [
77 | 'login' => 'Magoo',
78 | 'url' => 'http://example.com/author',
79 | ],
80 | 'url' => 'http://example.com/issue',
81 | 'merged' => true,
82 | 'closed' => false,
83 | 'labels' => [
84 | 'nodes' => [],
85 | ],
86 | ]),
87 | ],
88 | $milestone->entries()
89 | );
90 |
91 | self::assertSame(123, $milestone->number());
92 | self::assertTrue($milestone->closed());
93 | self::assertSame('http://example.com/milestone', $milestone->url()->__toString());
94 | self::assertSame('The title', $milestone->title());
95 | self::assertSame('The description', $milestone->description());
96 | $milestone->assertAllIssuesAreClosed();
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/test/unit/Github/CreateChangelogTextTest.php:
--------------------------------------------------------------------------------
1 | createMock(GenerateChangelog::class);
19 |
20 | $repositoryName = RepositoryName::fromFullName('doctrine/repository-name');
21 | $semVerVersion = SemVerVersion::fromMilestoneName('1.0.0');
22 |
23 | $generateChangelog->expects(self::once())
24 | ->method('__invoke')
25 | ->with($repositoryName, $semVerVersion)
26 | ->willReturn('Generated changelog');
27 |
28 | self::assertSame(
29 | <<<'RELEASE'
30 | Release [The title](http://example.com/milestone)
31 |
32 | The description
33 |
34 | Generated changelog
35 |
36 | RELEASE
37 | ,
38 | (new CreateChangelogText($generateChangelog))
39 | ->__invoke(
40 | Milestone::fromPayload([
41 | 'number' => 123,
42 | 'closed' => true,
43 | 'title' => 'The title',
44 | 'description' => 'The description',
45 | 'issues' => [
46 | 'nodes' => [
47 | [
48 | 'number' => 456,
49 | 'title' => 'Issue',
50 | 'author' => [
51 | 'login' => 'Magoo',
52 | 'url' => 'http://example.com/author',
53 | ],
54 | 'url' => 'http://example.com/issue',
55 | 'closed' => true,
56 | 'labels' => [
57 | 'nodes' => [],
58 | ],
59 | ],
60 | ],
61 | ],
62 | 'pullRequests' => [
63 | 'nodes' => [
64 | [
65 | 'number' => 789,
66 | 'title' => 'PR',
67 | 'author' => [
68 | 'login' => 'Magoo',
69 | 'url' => 'http://example.com/author',
70 | ],
71 | 'url' => 'http://example.com/issue',
72 | 'merged' => true,
73 | 'closed' => false,
74 | 'labels' => [
75 | 'nodes' => [
76 | [
77 | 'color' => 'aabbcc',
78 | 'name' => 'A label',
79 | 'url' => 'http://example.com/a-label',
80 | ],
81 | ],
82 | ],
83 | ],
84 | ],
85 | ],
86 | 'url' => 'http://example.com/milestone',
87 | ]),
88 | $repositoryName,
89 | $semVerVersion
90 | )
91 | );
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/Milestone.php:
--------------------------------------------------------------------------------
1 | */
30 | private $entries;
31 |
32 | /** @var Uri */
33 | private $url;
34 |
35 | private function __construct()
36 | {
37 | }
38 |
39 | /** @param array $payload */
40 | public static function fromPayload(array $payload) : self
41 | {
42 | Assert::that($payload)
43 | ->keyExists('number')
44 | ->keyExists('closed')
45 | ->keyExists('title')
46 | ->keyExists('description')
47 | ->keyExists('issues')
48 | ->keyExists('pullRequests')
49 | ->keyExists('url');
50 |
51 | Assert::that($payload['number'])
52 | ->integer()
53 | ->greaterThan(0);
54 |
55 | Assert::that($payload['closed'])
56 | ->boolean();
57 |
58 | Assert::that($payload['title'])
59 | ->string()
60 | ->notEmpty();
61 |
62 | Assert::that($payload['description'])
63 | ->nullOr()
64 | ->string();
65 |
66 | Assert::that($payload['issues'])
67 | ->isArray()
68 | ->keyExists('nodes');
69 |
70 | Assert::that($payload['pullRequests'])
71 | ->isArray()
72 | ->keyExists('nodes');
73 |
74 | Assert::that($payload['issues']['nodes'])
75 | ->isArray();
76 |
77 | Assert::that($payload['pullRequests']['nodes'])
78 | ->isArray();
79 |
80 | Assert::that($payload['url'])
81 | ->string()
82 | ->notEmpty();
83 |
84 | $instance = new self();
85 |
86 | $instance->number = $payload['number'];
87 | $instance->closed = $payload['closed'];
88 | $instance->title = $payload['title'];
89 | $instance->description = $payload['description'];
90 | $instance->url = new Uri($payload['url']);
91 |
92 | $instance->entries = array_merge(
93 | array_values(array_map([IssueOrPullRequest::class, 'fromPayload'], $payload['issues']['nodes'])),
94 | array_values(array_map([IssueOrPullRequest::class, 'fromPayload'], $payload['pullRequests']['nodes']))
95 | );
96 |
97 | return $instance;
98 | }
99 |
100 | public function number() : int
101 | {
102 | return $this->number;
103 | }
104 |
105 | public function closed() : bool
106 | {
107 | return $this->closed;
108 | }
109 |
110 | public function title() : string
111 | {
112 | return $this->title;
113 | }
114 |
115 | public function description() : ?string
116 | {
117 | return $this->description;
118 | }
119 |
120 | /** @return array */
121 | public function entries() : array
122 | {
123 | return $this->entries;
124 | }
125 |
126 | public function url() : UriInterface
127 | {
128 | return $this->url;
129 | }
130 |
131 | public function assertAllIssuesAreClosed() : void
132 | {
133 | Assert::thatAll(array_combine(
134 | array_map(static function (IssueOrPullRequest $entry) : string {
135 | return $entry->url()->__toString();
136 | }, $this->entries),
137 | array_map(static function (IssueOrPullRequest $entry) : bool {
138 | return $entry->closed();
139 | }, $this->entries)
140 | ))
141 | ->true();
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/test/unit/Github/Api/GraphQL/Query/GetMilestoneChangelog/Response/IssueOrPullRequestTest.php:
--------------------------------------------------------------------------------
1 | 123,
18 | 'title' => 'Yadda',
19 | 'author' => [
20 | 'login' => 'Magoo',
21 | 'url' => 'http://example.com/author',
22 | ],
23 | 'url' => 'http://example.com/issue',
24 | 'closed' => true,
25 | 'labels' => [
26 | 'nodes' => [
27 | [
28 | 'name' => 'BC Break',
29 | 'color' => 'abcabc',
30 | 'url' => 'http://example.com/bc-break',
31 | ],
32 | [
33 | 'name' => 'Question',
34 | 'color' => 'defdef',
35 | 'url' => 'http://example.com/question',
36 | ],
37 | ],
38 | ],
39 | ]);
40 |
41 | self::assertSame(123, $issue->number());
42 | self::assertTrue($issue->closed());
43 | self::assertSame('http://example.com/issue', $issue->url()->__toString());
44 | self::assertSame('Yadda', $issue->title());
45 | self::assertEquals(
46 | Author::fromPayload([
47 | 'login' => 'Magoo',
48 | 'url' => 'http://example.com/author',
49 | ]),
50 | $issue->author()
51 | );
52 | self::assertEquals(
53 | [
54 | Label::fromPayload([
55 | 'name' => 'BC Break',
56 | 'color' => 'abcabc',
57 | 'url' => 'http://example.com/bc-break',
58 | ]),
59 | Label::fromPayload([
60 | 'name' => 'Question',
61 | 'color' => 'defdef',
62 | 'url' => 'http://example.com/question',
63 | ]),
64 | ],
65 | $issue->labels()
66 | );
67 | }
68 |
69 | public function testPullRequest() : void
70 | {
71 | $issue = IssueOrPullRequest::fromPayload([
72 | 'number' => 123,
73 | 'title' => 'Yadda',
74 | 'author' => [
75 | 'login' => 'Magoo',
76 | 'url' => 'http://example.com/author',
77 | ],
78 | 'url' => 'http://example.com/issue',
79 | 'closed' => false,
80 | 'merged' => true,
81 | 'labels' => [
82 | 'nodes' => [
83 | [
84 | 'name' => 'BC Break',
85 | 'color' => 'abcabc',
86 | 'url' => 'http://example.com/bc-break',
87 | ],
88 | [
89 | 'name' => 'Question',
90 | 'color' => 'defdef',
91 | 'url' => 'http://example.com/question',
92 | ],
93 | ],
94 | ],
95 | ]);
96 |
97 | self::assertSame(123, $issue->number());
98 | self::assertTrue($issue->closed());
99 | self::assertSame('http://example.com/issue', $issue->url()->__toString());
100 | self::assertSame('Yadda', $issue->title());
101 | self::assertEquals(
102 | Author::fromPayload([
103 | 'login' => 'Magoo',
104 | 'url' => 'http://example.com/author',
105 | ]),
106 | $issue->author()
107 | );
108 | self::assertEquals(
109 | [
110 | Label::fromPayload([
111 | 'name' => 'BC Break',
112 | 'color' => 'abcabc',
113 | 'url' => 'http://example.com/bc-break',
114 | ]),
115 | Label::fromPayload([
116 | 'name' => 'Question',
117 | 'color' => 'defdef',
118 | 'url' => 'http://example.com/question',
119 | ]),
120 | ],
121 | $issue->labels()
122 | );
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/test/unit/Git/Value/BranchNameTest.php:
--------------------------------------------------------------------------------
1 | name());
22 | self::assertFalse($branch->isReleaseBranch());
23 | self::assertFalse($branch->isNextMajor());
24 | }
25 |
26 | /** @return array> */
27 | public function genericBranchNames() : array
28 | {
29 | return [
30 | ['foo'],
31 | ['a1.2.'],
32 | ['1.2.'],
33 | ['a1.2.3'],
34 | ['1.2.3.4'],
35 | ['foo/bar'],
36 | ['foo-bar-baz'],
37 | ['a/b/c/1/2/3'],
38 | ];
39 | }
40 |
41 | public function testDisallowsEmptyBranchName() : void
42 | {
43 | $this->expectException(AssertionFailedException::class);
44 |
45 | BranchName::fromName('');
46 | }
47 |
48 | public function testMasterIsNextMajorRelease() : void
49 | {
50 | $branch = BranchName::fromName('master');
51 |
52 | self::assertTrue($branch->isNextMajor());
53 | self::assertFalse($branch->isReleaseBranch());
54 | }
55 |
56 | /**
57 | * @dataProvider releaseBranches
58 | */
59 | public function testDetectsReleaseBranchVersions(string $inputName, int $major, int $minor) : void
60 | {
61 | $branch = BranchName::fromName($inputName);
62 |
63 | self::assertFalse($branch->isNextMajor());
64 | self::assertTrue($branch->isReleaseBranch());
65 | self::assertSame([$major, $minor], $branch->majorAndMinor());
66 | }
67 |
68 | /**
69 | * @return array>
70 | *
71 | * @psalm-return array
72 | */
73 | public function releaseBranches() : array
74 | {
75 | return [
76 | ['1.2', 1, 2],
77 | ['v1.2', 1, 2],
78 | ['33.44.x', 33, 44],
79 | ];
80 | }
81 |
82 | public function testEquals() : void
83 | {
84 | self::assertFalse(BranchName::fromName('foo')->equals(BranchName::fromName('bar')));
85 | self::assertFalse(BranchName::fromName('bar')->equals(BranchName::fromName('foo')));
86 | self::assertTrue(BranchName::fromName('foo')->equals(BranchName::fromName('foo')));
87 | }
88 |
89 | /**
90 | * @dataProvider versionEqualityProvider
91 | */
92 | public function testIsForVersion(string $milestoneName, string $branchName, bool $expected) : void
93 | {
94 | self::assertSame(
95 | $expected,
96 | BranchName::fromName($branchName)
97 | ->isForVersion(SemVerVersion::fromMilestoneName($milestoneName))
98 | );
99 | }
100 |
101 | /**
102 | * @return array>
103 | *
104 | * @psalm-return array
105 | */
106 | public function versionEqualityProvider() : array
107 | {
108 | return [
109 | ['1.0.0', '1.0.x', true],
110 | ['1.0.0', '1.1.x', false],
111 | ['1.0.0', '0.9.x', false],
112 | ['2.0.0', '1.0.x', false],
113 | ['2.0.0', '2.0.x', true],
114 | ['2.0.0', '2.0', true],
115 | ['2.0.0', '2.1', false],
116 | ];
117 | }
118 |
119 | /**
120 | * @dataProvider newerVersionComparisonProvider
121 | */
122 | public function testIsForNewerVersionThan(string $milestoneName, string $branchName, bool $expected) : void
123 | {
124 | self::assertSame(
125 | $expected,
126 | BranchName::fromName($branchName)
127 | ->isForNewerVersionThan(SemVerVersion::fromMilestoneName($milestoneName))
128 | );
129 | }
130 |
131 | /**
132 | * @return array>
133 | *
134 | * @psalm-return array
135 | */
136 | public function newerVersionComparisonProvider() : array
137 | {
138 | return [
139 | ['1.0.0', '1.0.x', false],
140 | ['1.0.0', '1.1.x', true],
141 | ['1.0.0', '0.9.x', false],
142 | ['2.0.0', '1.0.x', false],
143 | ['2.0.0', '2.0.x', false],
144 | ['2.0.0', '2.0', false],
145 | ['2.0.0', '2.1', true],
146 | ['2.0.0', '1.9', false],
147 | ];
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/test/unit/Git/Value/MergeTargetCandidateBranchesTest.php:
--------------------------------------------------------------------------------
1 | targetBranchFor(SemVerVersion::fromMilestoneName('1.99.0'))
29 | );
30 | self::assertNull($branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.99.0')));
31 |
32 | self::assertEquals(
33 | BranchName::fromName('master'),
34 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('2.0.0'))
35 | );
36 | self::assertNull($branches->branchToMergeUp(SemVerVersion::fromMilestoneName('2.0.0')));
37 |
38 | self::assertEquals(
39 | BranchName::fromName('1.2'),
40 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.2.3'))
41 | );
42 | self::assertEquals(
43 | BranchName::fromName('1.4'), // note: there is no 1.3
44 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.2.3'))
45 | );
46 |
47 | self::assertEquals(
48 | BranchName::fromName('1.5'),
49 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.5.99'))
50 | );
51 | self::assertEquals(
52 | BranchName::fromName('master'),
53 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.5.99'))
54 | );
55 | self::assertEquals(
56 | BranchName::fromName('master'),
57 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.6.0'))
58 | );
59 |
60 | self::assertEquals(
61 | BranchName::fromName('1.0'),
62 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.0.1'))
63 | );
64 | self::assertEquals(
65 | BranchName::fromName('1.1'),
66 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.0.1'))
67 | );
68 | }
69 |
70 | public function testCannotGetNextMajorBranchIfNoneExists() : void
71 | {
72 | $branches = MergeTargetCandidateBranches::fromAllBranches(
73 | BranchName::fromName('1.1'),
74 | BranchName::fromName('1.2'),
75 | BranchName::fromName('potato')
76 | );
77 |
78 | self::assertNull(
79 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.6.0')),
80 | 'Cannot release next minor, since next minor branch does not exist'
81 | );
82 | self::assertNull(
83 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.6.0')),
84 | 'Cannot merge up next minor, since no next branch exists'
85 | );
86 | self::assertNull(
87 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('2.0.0')),
88 | 'Cannot release next major, since next major branch does not exist'
89 | );
90 | self::assertNull(
91 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('2.0.0')),
92 | 'Cannot merge up next major, since no next branch exists'
93 | );
94 | self::assertNull(
95 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.2.1')),
96 | 'Cannot merge up: no master branch exists'
97 | );
98 | }
99 |
100 | public function testWillPickNewMajorReleaseBranchIfNoCurrentReleaseBranchExists() : void
101 | {
102 | $branches = MergeTargetCandidateBranches::fromAllBranches(
103 | BranchName::fromName('1.1'),
104 | BranchName::fromName('1.2'),
105 | BranchName::fromName('master')
106 | );
107 |
108 | self::assertEquals(
109 | BranchName::fromName('1.2'),
110 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.2.31')),
111 | 'Next patch release will be tagged from active minor branch'
112 | );
113 | self::assertEquals(
114 | BranchName::fromName('master'),
115 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.2.31')),
116 | '1.2.x will be merged into master'
117 | );
118 | self::assertEquals(
119 | BranchName::fromName('master'),
120 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.3.0')),
121 | 'Next minor release will be tagged from active master branch'
122 | );
123 | self::assertNull(
124 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('1.3.0')),
125 | '1.3.0 won\'t be merged up, since there\'s no further branches to merge to'
126 | );
127 | self::assertEquals(
128 | BranchName::fromName('master'),
129 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('2.0.0')),
130 | 'Next major release will be tagged from active master branch'
131 | );
132 | self::assertNull(
133 | $branches->branchToMergeUp(SemVerVersion::fromMilestoneName('2.0.0')),
134 | '2.0.0 won\'t be merged up, since there\'s no further branches to merge to'
135 | );
136 | }
137 |
138 | /** @link https://github.com/doctrine/automatic-releases/pull/23#discussion_r344499867 */
139 | public function testWillNotPickTargetIfNoMatchingReleaseBranchAndNewerReleaseBranchesExist() : void
140 | {
141 | $branches = MergeTargetCandidateBranches::fromAllBranches(
142 | BranchName::fromName('1.2.x'),
143 | BranchName::fromName('master')
144 | );
145 |
146 | self::assertNull(
147 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.1.0')),
148 | '1.1.0 can\'t have a target branch, since 1.2.x already exists'
149 | );
150 | }
151 |
152 | /** @link https://github.com/doctrine/automatic-releases/pull/23#discussion_r344499867 */
153 | public function testWillNotPickPatchTargetIfNoMatchingReleaseBranchAndNewerReleaseBranchesExist() : void
154 | {
155 | $branches = MergeTargetCandidateBranches::fromAllBranches(
156 | BranchName::fromName('1.0.x'),
157 | BranchName::fromName('master')
158 | );
159 |
160 | self::assertNull(
161 | $branches->targetBranchFor(SemVerVersion::fromMilestoneName('1.1.1')),
162 | '1.1.1 can\'t have a target branch, since 1.1.x doesn\'t exist, but patches require a release branch'
163 | );
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/public/index.php:
--------------------------------------------------------------------------------
1 | mustRun();
62 |
63 | (new Process(['mkdir', $buildDir]))
64 | ->mustRun();
65 | };
66 |
67 | $cloneRepository = static function (
68 | UriInterface $repositoryUri,
69 | string $targetPath,
70 | string $gitAuthorName,
71 | string $gitAuthorEmail
72 | ) : void {
73 | (new Process(['git', 'clone', $repositoryUri->__toString(), $targetPath]))
74 | ->mustRun();
75 |
76 | (new Process(['git', 'config', 'user.email', $gitAuthorEmail], $targetPath))
77 | ->mustRun();
78 |
79 | (new Process(['git', 'config', 'user.name', $gitAuthorName], $targetPath))
80 | ->mustRun();
81 | };
82 |
83 | $getBranches = static function (string $repositoryDirectory) : MergeTargetCandidateBranches {
84 | (new Process(['git', 'fetch'], $repositoryDirectory))
85 | ->mustRun();
86 |
87 | $branches = array_filter(explode(
88 | "\n",
89 | (new Process(['git', 'branch', '-r'], $repositoryDirectory))
90 | ->mustRun()
91 | ->getOutput()
92 | ));
93 |
94 | return MergeTargetCandidateBranches::fromAllBranches(...array_map(static function (string $branch) : BranchName {
95 | /** @var string $sanitizedBranch */
96 | $sanitizedBranch = preg_replace(
97 | '~^(?:remotes/)?origin/~',
98 | '',
99 | trim($branch, "* \t\n\r\0\x0B")
100 | );
101 |
102 | return BranchName::fromName($sanitizedBranch);
103 | }, $branches));
104 | };
105 |
106 | $createTag = static function (
107 | string $repositoryDirectory,
108 | BranchName $sourceBranch,
109 | string $tagName,
110 | string $changelog,
111 | SecretKeyId $keyId
112 | ) : void {
113 | $tagFileName = tempnam(sys_get_temp_dir(), 'created_tag');
114 |
115 | file_put_contents($tagFileName, $changelog);
116 |
117 | (new Process(['git', 'checkout', $sourceBranch->name()], $repositoryDirectory))
118 | ->mustRun();
119 |
120 | (new Process(
121 | ['git', 'tag', $tagName, '-F', $tagFileName, '--cleanup=verbatim', '--local-user=' . $keyId->id()],
122 | $repositoryDirectory
123 | ))
124 | ->mustRun();
125 | };
126 |
127 | $push = static function (
128 | string $repositoryDirectory,
129 | string $symbol,
130 | ?string $alias = null
131 | ) : void {
132 | if ($alias === null) {
133 | (new Process(['git', 'push', 'origin', $symbol], $repositoryDirectory))
134 | ->mustRun();
135 |
136 | return;
137 | }
138 |
139 | $localTemporaryBranch = uniqid('temporary-branch', true);
140 |
141 | (new Process(['git', 'branch', $localTemporaryBranch, $symbol], $repositoryDirectory))
142 | ->mustRun();
143 |
144 | (new Process(['git', 'push', 'origin', $localTemporaryBranch . ':' . $alias], $repositoryDirectory))
145 | ->mustRun();
146 | };
147 |
148 | $importGpgKey = static function (string $keyContents) : SecretKeyId {
149 | $keyFileName = tempnam(sys_get_temp_dir(), 'imported-key');
150 |
151 | file_put_contents($keyFileName, $keyContents);
152 |
153 | $output = (new Process(['gpg', '--import', $keyFileName]))
154 | ->mustRun()
155 | ->getErrorOutput();
156 |
157 | Assert::that($output)
158 | ->regex('/key\\s+([A-F0-9]+):\\s+secret\\s+key\\s+imported/im');
159 |
160 | preg_match('/key\\s+([A-F0-9]+):\\s+secret\\s+key\\s+imported/im', $output, $matches);
161 |
162 | assert(is_array($matches));
163 |
164 | return SecretKeyId::fromBase16String($matches[1]);
165 | };
166 |
167 | $request = ServerRequestFactory::fromGlobals();
168 |
169 | $environment = Variables::fromEnvironment();
170 |
171 | (new VerifyRequestSignature())->__invoke($request, $environment->githubHookSecret());
172 |
173 | if (! MilestoneClosedEvent::appliesToRequest($request)) {
174 | echo 'Event does not apply.';
175 |
176 | return;
177 | }
178 |
179 | $postData = $request->getParsedBody();
180 |
181 | assert(is_array($postData));
182 |
183 | Assert::that($postData)
184 | ->keyExists('payload');
185 |
186 | Assert::that($postData['payload'])
187 | ->isJsonString();
188 |
189 | $milestone = MilestoneClosedEvent::fromEventJson($postData['payload']);
190 | $repositoryName = $milestone->repository();
191 |
192 | if (class_exists(Profiler::class, false)) {
193 | Profiler::setCustomVariable('repository', $repositoryName->owner() . '/' . $repositoryName->name());
194 | Profiler::setCustomVariable('version', $milestone->version()->fullReleaseName());
195 | }
196 |
197 | $repositoryName->assertMatchesOwner($environment->githubOrganisation()); // @TODO limit via ENV?
198 |
199 | $repository = $repositoryName->uriWithTokenAuthentication($environment->githubToken());
200 | $releasedRepositoryLocalPath = $buildDir . '/' . $repositoryName->name();
201 |
202 | $importedKey = $importGpgKey($environment->signingSecretKey());
203 |
204 | $cleanBuildDir();
205 | $cloneRepository(
206 | $repository,
207 | $releasedRepositoryLocalPath,
208 | $environment->gitAuthorName(),
209 | $environment->gitAuthorEmail()
210 | );
211 |
212 | $candidates = $getBranches($releasedRepositoryLocalPath);
213 |
214 | $releaseVersion = $milestone->version();
215 |
216 | $milestoneChangelog = (new GetMilestoneChangelog(new RunGraphQLQuery(
217 | Psr17FactoryDiscovery::findRequestFactory(),
218 | HttpClientDiscovery::find(),
219 | $environment->githubToken()
220 | )))->__invoke(
221 | $repositoryName,
222 | $milestone->milestoneNumber()
223 | );
224 |
225 | $milestoneChangelog->assertAllIssuesAreClosed();
226 |
227 | $releaseBranch = $candidates->targetBranchFor($milestone->version());
228 |
229 | if ($releaseBranch === null) {
230 | throw new RuntimeException(sprintf(
231 | 'No valid release branch found for version %s',
232 | $milestone->version()->fullReleaseName()
233 | ));
234 | }
235 |
236 | $changelog = (new CreateChangelogText(JwageGenerateChangelog::create(
237 | Psr17FactoryDiscovery::findRequestFactory(),
238 | HttpClientDiscovery::find(),
239 | )))
240 | ->__invoke(
241 | $milestoneChangelog,
242 | $milestone->repository(),
243 | $milestone->version()
244 | );
245 |
246 | $tagName = $releaseVersion->fullReleaseName();
247 |
248 | $createTag(
249 | $releasedRepositoryLocalPath,
250 | $releaseBranch,
251 | $releaseVersion->fullReleaseName(),
252 | $changelog,
253 | $importedKey
254 | );
255 |
256 | $push($releasedRepositoryLocalPath, $tagName);
257 | $push($releasedRepositoryLocalPath, $tagName, $releaseVersion->targetReleaseBranchName()->name());
258 |
259 | $mergeUpTarget = $candidates->branchToMergeUp($milestone->version());
260 |
261 | $releaseUrl = (new CreateRelease(
262 | Psr17FactoryDiscovery::findRequestFactory(),
263 | HttpClientDiscovery::find(),
264 | $environment->githubToken()
265 | ))->__invoke(
266 | $repositoryName,
267 | $releaseVersion,
268 | $changelog
269 | );
270 |
271 | if ($mergeUpTarget !== null) {
272 | $mergeUpBranch = BranchName::fromName(
273 | $releaseBranch->name()
274 | . '-merge-up-into-'
275 | . $mergeUpTarget->name()
276 | . uniqid('_', true) // This is to ensure that a new merge-up pull request is created even if one already exists
277 | );
278 | $push($releasedRepositoryLocalPath, $releaseBranch->name(), $mergeUpBranch->name());
279 |
280 | (new CreatePullRequest(
281 | Psr17FactoryDiscovery::findRequestFactory(),
282 | HttpClientDiscovery::find(),
283 | $environment->githubToken()
284 | ))->__invoke(
285 | $repositoryName,
286 | $mergeUpBranch,
287 | $mergeUpTarget,
288 | 'Merge release ' . $tagName . ' into ' . $mergeUpTarget->name(),
289 | $changelog
290 | );
291 | }
292 |
293 | echo 'Released: ' . $releaseUrl->__toString();
294 | })();
295 |
--------------------------------------------------------------------------------