├── 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 | --------------------------------------------------------------------------------