diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..f9dac60 --- /dev/null +++ b/.env.dist @@ -0,0 +1 @@ +GITHUB_API_TOKEN= diff --git a/.gitignore b/.gitignore index afff842..ac47f34 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /tests/_cache /var/ /docs.phar +/.env diff --git a/bin/create_release b/bin/create_release new file mode 100755 index 0000000..1cbe66c --- /dev/null +++ b/bin/create_release @@ -0,0 +1,32 @@ +#!/usr/bin/env php +createHttpClient(), new Compiler()); + + if ($argc === 1) { + throw new RuntimeException('Not enough arguments. usage: "./bin/release tag [release_name [release_description]]"'); + } + + array_shift($argv); + + $releaser->createRelease(...$argv); +} catch (Exception $e) { + if ($e instanceof ReleaseFailed) { + echo $e->toString(); + } else { + echo 'Failed to create a new release: ['.get_class($e).'] '.$e->getMessage().' at '.$e->getFile().':'.$e->getLine()."\n"; + } + + exit(1); +} diff --git a/composer.json b/composer.json index 459a086..2111e33 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,13 @@ "symfony/dom-crawler": "^4.1", "symfony/css-selector": "^4.1", "symfony/console": "^4.1", - "twig/twig": "^2.7.3" + "twig/twig": "^2.7.3", + "symfony/http-client": "^4.3" }, "require-dev": { "gajus/dindent": "^2.0", "symfony/phpunit-bridge": "^4.1", - "symfony/process": "^4.2" + "symfony/process": "^4.2", + "symfony/dotenv": "^4.3" } } diff --git a/composer.lock b/composer.lock index 6690bad..4cb0478 100644 --- a/composer.lock +++ b/composer.lock @@ -1,10 +1,10 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "9782b4b0d78029875484174bac915130", + "content-hash": "e3c1dde6a88639effbdefc0cf424f2f3", "packages": [ { "name": "doctrine/event-manager", @@ -429,6 +429,53 @@ ], "time": "2016-08-06T14:39:51+00:00" }, + { + "name": "psr/log", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2018-11-20T15:27:04+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -824,6 +871,125 @@ "homepage": "https://symfony.com", "time": "2019-08-14T12:26:46+00:00" }, + { + "name": "symfony/http-client", + "version": "v4.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "9a4fa769269ed730196a5c52c742b30600cf1e87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/9a4fa769269ed730196a5c52c742b30600cf1e87", + "reference": "9a4fa769269ed730196a5c52c742b30600cf1e87", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/log": "^1.0", + "symfony/http-client-contracts": "^1.1.6", + "symfony/polyfill-php73": "^1.11" + }, + "provide": { + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "1.1" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "psr/http-client": "^1.0", + "symfony/http-kernel": "^4.3", + "symfony/process": "^4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony HttpClient component", + "homepage": "https://symfony.com", + "time": "2019-08-20T14:27:59+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v1.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "6005fe61a33724405d56eb5b055d5d370192a1bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/6005fe61a33724405d56eb5b055d5d370192a1bd", + "reference": "6005fe61a33724405d56eb5b055d5d370192a1bd", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "suggest": { + "symfony/http-client-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-08-08T10:05:21+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.12.0", @@ -1171,6 +1337,63 @@ ], "time": "2014-10-08T10:03:04+00:00" }, + { + "name": "symfony/dotenv", + "version": "v4.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "1785b18148a016b8f4e6a612291188d568e1f9cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/1785b18148a016b8f4e6a612291188d568e1f9cd", + "reference": "1785b18148a016b8f4e6a612291188d568e1f9cd", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "require-dev": { + "symfony/process": "~3.4|~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "time": "2019-08-03T21:50:52+00:00" + }, { "name": "symfony/phpunit-bridge", "version": "v4.3.4", diff --git a/src/Application.php b/src/Application.php index 5e69204..c8ebdb8 100644 --- a/src/Application.php +++ b/src/Application.php @@ -7,6 +7,7 @@ use Symfony\Component\Console\Input\InputOption; use SymfonyDocsBuilder\Command\BuildDocsCommand; use SymfonyDocsBuilder\Command\CheckUrlsCommand; +use SymfonyDocsBuilder\Command\GithubReleaseCommand; class Application { @@ -41,6 +42,7 @@ public function run(InputInterface $input): int ); $this->application->getDefinition()->addOption($inputOption); $this->application->add(new BuildDocsCommand($this->buildContext)); + $this->application->add(new GithubReleaseCommand()); return $this->application->run($input); } diff --git a/src/Phar/Compiler.php b/src/Phar/Compiler.php index 525af7e..a55cbba 100644 --- a/src/Phar/Compiler.php +++ b/src/Phar/Compiler.php @@ -59,7 +59,7 @@ public function compile($pharFile = 'docs.phar') $finder->files() ->ignoreVCS(true) ->name('*.php') - ->notName('Compiler.php') + ->exclude(['Release', 'Phar']) ->in(__DIR__.'/..') ; foreach ($finder as $file) { diff --git a/src/Release/Exception/DeleteReleaseFailed.php b/src/Release/Exception/DeleteReleaseFailed.php new file mode 100644 index 0000000..37cc75d --- /dev/null +++ b/src/Release/Exception/DeleteReleaseFailed.php @@ -0,0 +1,29 @@ +originException = $originException; + + parent::__construct('Error while deleting release.', 0, $previous); + } + + public function toString(): string + { + return sprintf( + "%s\n\nOriginal exception was: [%s]\n\t\"%s\"\n\tat %s:%s\n", + parent::toString(), + get_class($this->originException), + $this->originException->getMessage(), + $this->originException->getFile(), + $this->originException->getLine() + ); + } +} diff --git a/src/Release/Exception/ReleaseFailed.php b/src/Release/Exception/ReleaseFailed.php new file mode 100644 index 0000000..1a59430 --- /dev/null +++ b/src/Release/Exception/ReleaseFailed.php @@ -0,0 +1,44 @@ +getCode()) { + $message = 'Error while trying to create release: Invalid token.'; + } else { + $message = 'Error while trying to create release.'; + } + + return new self($message, 0, $previous); + } + + public static function whileAttachingAssetToRelease(HttpExceptionInterface $previous): self + { + return new self('Error while adding asset to release.', 0, $previous); + } + + public static function whilePublishingRelease(HttpExceptionInterface $previous): self + { + return new self('Error while publishing release. Maybe the tag name already exists?', 0, $previous); + } + + public function toString(): string + { + return sprintf( + "Failed to create a new release: [%s]\n\t\"%s\"\n\tat %s:%s\n\nHttpException was: [%s]\n\t\"%s\"\n\tat %s:%s\n", + self::class, + $this->getMessage(), + $this->getFile(), + $this->getLine(), + get_class($this->getPrevious()), + $this->getPrevious()->getMessage(), + $this->getPrevious()->getFile(), + $this->getPrevious()->getLine() + ); + } +} diff --git a/src/Release/GithubApiHttpClientFactory.php b/src/Release/GithubApiHttpClientFactory.php new file mode 100644 index 0000000..44b434e --- /dev/null +++ b/src/Release/GithubApiHttpClientFactory.php @@ -0,0 +1,37 @@ +load(__DIR__.'/../../.env'); + + if (empty($_SERVER['GITHUB_API_TOKEN'])) { + throw new \RuntimeException('Please fill "GITHUB_API_TOKEN" in file "[PROJECT_DIR]/.env"'); + } + + $this->githubApiToken = $_SERVER['GITHUB_API_TOKEN']; + } + + public function createHttpClient(): HttpClientInterface + { + $client = HttpClient::create( + [ + 'headers' => [ + 'Authorization' => sprintf('token %s', $this->githubApiToken), + ], + ] + ); + + return $client; + } +} diff --git a/src/Release/Releaser.php b/src/Release/Releaser.php new file mode 100644 index 0000000..bc9779b --- /dev/null +++ b/src/Release/Releaser.php @@ -0,0 +1,113 @@ +client = $client; + $this->compiler = $compiler; + } + + public function createRelease(string $tag, string $name = 'Symfony docs builder %s', string $description = 'Symfony docs builder %s'): void + { + if (!preg_match('/^v\d+\.\d+\.\d+$/', $tag)) { + throw new \RuntimeException(sprintf('"%s" is not a valid tag.', $tag)); + } + + $this->compiler->compile(); + + $this->addAssetToRelease($releaseId = $this->createDraftRelease($tag, $name, $description)); + + $this->publishRelease($releaseId); + } + + private function createDraftRelease(string $tag, string $name, string $description): int + { + try { + $response = $this->client->request( + 'POST', + sprintf('https://api.github.com/repos/%s/%s/releases', self::GITHUB_USER, self::GITHUB_REPO), + [ + 'json' => [ + 'tag_name' => $tag, + 'target_commitish' => 'master', + 'name' => sprintf($name, $tag), + 'description' => sprintf($description, $tag), + 'draft' => true, + 'prerelease' => false, + ], + ] + ); + + return (int) $response->toArray()['id']; + } catch (HttpExceptionInterface $exception) { + throw ReleaseFailed::whileCreatingDraft($exception); + } + } + + private function addAssetToRelease(int $releaseId): void + { + try { + $this->client->request( + 'POST', + sprintf( + 'https://uploads.github.com/repos/%s/%s/releases/%s/assets?name=docs.phar', + self::GITHUB_USER, + self::GITHUB_REPO, + $releaseId + ), + [ + 'headers' => ['Content-Type' => 'application/octet-stream'], + 'body' => file_get_contents(__DIR__.'/../../docs.phar'), + ] + ); + } catch (HttpExceptionInterface $exception) { + $this->deleteRelease($releaseId, ReleaseFailed::whileAttachingAssetToRelease($exception)); + } + } + + private function publishRelease(int $releaseId): void + { + try { + $this->client->request( + 'PATCH', + sprintf('https://api.github.com/repos/%s/%s/releases/%s', self::GITHUB_USER, self::GITHUB_REPO, $releaseId), + [ + 'json' => [ + 'draft' => false, + ], + ] + ); + } catch (HttpExceptionInterface $exception) { + $this->deleteRelease($releaseId, ReleaseFailed::whilePublishingRelease($exception)); + } + } + + private function deleteRelease(int $releaseId, ReleaseFailed $previous): void + { + try { + $this->client->request( + 'DELETE', + sprintf('https://api.github.com/repos/%s/%s/releases/%s', self::GITHUB_USER, self::GITHUB_REPO, $releaseId) + ); + } catch (HttpExceptionInterface $exception) { + throw new DeleteReleaseFailed($previous, $exception); + } + + throw $previous; + } +} diff --git a/tests/Release/ReleaserTest.php b/tests/Release/ReleaserTest.php new file mode 100644 index 0000000..212c51e --- /dev/null +++ b/tests/Release/ReleaserTest.php @@ -0,0 +1,98 @@ +createMock(Compiler::class)); + + $compiler->expects($this->never())->method('compile'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('"invalid tag" is not a valid tag.'); + + $releaser->createRelease('invalid tag'); + } + + public function testCreateRelease(): void + { + $callback = static function ($method, $url) { + switch (true) { + case 'POST' === $method + && 'https://api.github.com/repos/weaverryan/docs-builder/releases' === $url: + return new MockResponse('{"id":1}'); + case 'POST' === $method + && 'https://uploads.github.com/repos/weaverryan/docs-builder/releases/1/assets?name=docs.phar' === $url: + case 'PATCH' === $method + && 'https://api.github.com/repos/weaverryan/docs-builder/releases/1' === $url: + return new MockResponse(); + } + + self::fail(sprintf("Unexpected request:\n- method: %s\n- url: %s", $method, $url)); + }; + + $releaser = new Releaser(new MockHttpClient($callback), $compiler = $this->createMock(Compiler::class)); + + $compiler->expects($this->once())->method('compile'); + + $releaser->createRelease('v1.0.0'); + } + + public function testCreateReleaseThrowExceptionIf401IsReturned(): void + { + $releaser = new Releaser(new MockHttpClient([new MockResponse('', ['http_code' => 401])]), $this->createMock(Compiler::class)); + + $this->expectException(ReleaseFailed::class); + $this->expectExceptionMessage('Error while trying to create release: Invalid token.'); + + $releaser->createRelease('v1.0.0'); + } + + public function testReleaseIsDeletedIfAddAssetReturnsAnError(): void + { + $callback = static function ($method, $url) { + switch (true) { + case 'POST' === $method + && 'https://api.github.com/repos/weaverryan/docs-builder/releases' === $url: + return new MockResponse('{"id":1}'); + case 'POST' === $method + && 'https://uploads.github.com/repos/weaverryan/docs-builder/releases/1/assets?name=docs.phar' === $url: + return new MockResponse('', ['http_code' => 500]); + case 'DELETE' === $method + && 'https://api.github.com/repos/weaverryan/docs-builder/releases/1' === $url: + return new MockResponse(); + } + + self::fail(sprintf("Unexpected request:\n- method: %s\n- url: %s", $method, $url)); + }; + + $this->expectException(ReleaseFailed::class); + $this->expectExceptionMessage('Error while adding asset to release.'); + + $releaser = new Releaser(new MockHttpClient($callback), $this->createMock(Compiler::class)); + + $releaser->createRelease('v1.0.0'); + } +} \ No newline at end of file