diff --git a/ChangeLog b/ChangeLog index 46bd765..5139459 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,15 @@ +2022-11-03 [RELEASE] Release of frontend-asset-handler 1.3.0 (Elias Häußler) +2022-11-03 20ad80d [DOCS] Mention required scope for GitLab access tokens (Elias Häußler) +2022-11-03 183b8a5 [DOCS] Improve documentation about VCS providers (Elias Häußler) +2022-11-03 5455408 [BUGFIX] Harden check for latest deployment status (Elias Häußler) +2022-11-03 6f17b3f [TASK] Ask for additional variables on initializing GitHub VCS provider (Elias Häußler) +2022-11-02 9f03ae8 [BUGFIX] Disable invalid behavior of ReadOnlyPropertyRector (Elias Häußler) +2022-09-16 777b3cc [FEATURE] Add support for GitHub as VCS provider (Elias Häußler) +2022-11-03 5216a21 [TASK] Make codebase compatible with PHPStan 1.9.x (Elias Häußler) +2022-11-03 61f19d0 [TASK]: Bump phpstan/phpstan from 1.8.11 to 1.9.0 (dependabot[bot]) +2022-11-02 6167440 [BUGFIX] Promote all properties in GitlabVcsProvider (Elias Häußler) +2022-11-02 a618d1e [TASK]: Bump friendsofphp/php-cs-fixer from 3.12.0 to 3.13.0 (dependabot[bot]) + 2022-11-02 [RELEASE] Release of frontend-asset-handler 1.2.6 (Elias Häußler) 2022-11-02 b573e13 [DOCS] Use less badges in README.md (Elias Häußler) 2022-11-01 2ed4e53 [TASK] Enable Dependabot auto-merge for minor and patch updates (Elias Häußler) diff --git a/composer.json b/composer.json index 71fe5bb..0995f62 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "ergebnis/json-normalizer": ">= 1.0 < 3.0", "ergebnis/json-printer": "^3.0", "ergebnis/json-schema-validator": "^2.0", + "gmostafa/php-graphql-client": "^1.13", "guzzlehttp/guzzle": "^7.0", "guzzlehttp/psr7": ">= 1.8 < 3.0", "justinrainbow/json-schema": "^5.2", diff --git a/composer.lock b/composer.lock index 3ef11eb..8173541 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e890cc54378889b4ce694f50ebd54ed8", + "content-hash": "c0113acaeea5316f12f153a0b3f759e0", "packages": [ { "name": "ergebnis/json-normalizer", @@ -205,6 +205,71 @@ ], "time": "2021-12-13T16:54:56+00:00" }, + { + "name": "gmostafa/php-graphql-client", + "version": "v1.13", + "source": { + "type": "git", + "url": "https://github.com/mghoneimy/php-graphql-client.git", + "reference": "caadeba3e8af0d3d5cf6481e393cf933f3a83f3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mghoneimy/php-graphql-client/zipball/caadeba3e8af0d3d5cf6481e393cf933f3a83f3c", + "reference": "caadeba3e8af0d3d5cf6481e393cf933f3a83f3c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.3|^7.0.1", + "php": "^7.1 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0" + }, + "conflict": { + "guzzlehttp/psr7": "< 1.7.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.186", + "codacy/coverage": "^1.4", + "phpunit/phpunit": "^7.5|^8.0|^9.0" + }, + "suggest": { + "aws/aws-sdk-php": "Move this package to require section to use AWS IAM authorization", + "gmostafa/php-graphql-oqm": "To have object-to-query mapping support" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mostafa Ghoneimy", + "email": "emostafagh@gmail.com" + } + ], + "description": "GraphQL client and query builder.", + "keywords": [ + "builder", + "client", + "graph-ql", + "graphql", + "php", + "query", + "query-builder" + ], + "support": { + "issues": "https://github.com/mghoneimy/php-graphql-client/issues", + "source": "https://github.com/mghoneimy/php-graphql-client/tree/v1.13" + }, + "time": "2021-08-10T12:39:57+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.5.0", @@ -2662,16 +2727,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.12.0", + "version": "v3.13.0", "source": { "type": "git", - "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "eae11d945e2885d86e1c080eec1bb30a2aa27998" + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "a6232229a8309e8811dc751c28b91cb34b2943e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/eae11d945e2885d86e1c080eec1bb30a2aa27998", - "reference": "eae11d945e2885d86e1c080eec1bb30a2aa27998", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a6232229a8309e8811dc751c28b91cb34b2943e1", + "reference": "a6232229a8309e8811dc751c28b91cb34b2943e1", "shasum": "" }, "require": { @@ -2695,7 +2760,7 @@ }, "require-dev": { "justinrainbow/json-schema": "^5.2", - "keradus/cli-executor": "^1.5", + "keradus/cli-executor": "^2.0", "mikey179/vfsstream": "^1.6.10", "php-coveralls/php-coveralls": "^2.5.2", "php-cs-fixer/accessible-object": "^1.1", @@ -2738,8 +2803,8 @@ ], "description": "A tool to automatically fix PHP code style", "support": { - "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues", - "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v3.12.0" + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.13.0" }, "funding": [ { @@ -2747,7 +2812,7 @@ "type": "github" } ], - "time": "2022-10-12T14:20:51+00:00" + "time": "2022-10-31T19:28:50+00:00" }, { "name": "idiosyncratic/editorconfig", @@ -3086,16 +3151,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.8.11", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "46e223dd68a620da18855c23046ddb00940b4014" + "reference": "e08de53a5eec983de78a787a88e72518cf8fe43a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46e223dd68a620da18855c23046ddb00940b4014", - "reference": "46e223dd68a620da18855c23046ddb00940b4014", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e08de53a5eec983de78a787a88e72518cf8fe43a", + "reference": "e08de53a5eec983de78a787a88e72518cf8fe43a", "shasum": "" }, "require": { @@ -3125,7 +3190,7 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan/issues", - "source": "https://github.com/phpstan/phpstan/tree/1.8.11" + "source": "https://github.com/phpstan/phpstan/tree/1.9.0" }, "funding": [ { @@ -3141,7 +3206,7 @@ "type": "tidelift" } ], - "time": "2022-10-24T15:45:13+00:00" + "time": "2022-11-03T07:26:48+00:00" }, { "name": "phpstan/phpstan-phpunit", diff --git a/docs/components/vcs-providers.md b/docs/components/vcs-providers.md index 312fbac..87feaa8 100644 --- a/docs/components/vcs-providers.md +++ b/docs/components/vcs-providers.md @@ -1,11 +1,40 @@ # VCS Providers +The Frontend Assets Handler is able to interact with the asset sources on VCS through +specific providers. Each VCS provider requires configuration. Consult the appropriate +provider classes to find out what configuration is expected. + +## [`GithubVcsProvider`](../../src/Vcs/GithubVcsProvider.php) + +Interacting with assets on GitHub requires an access token with `repo` scope. + +It supports the following additional configuration: + +### `access-token` + +An access token to identify requests to the GitLab API. This token is supplied as a +`Authorization` header with each request. + +* Required: **yes** +* Default: **–** + +### `repository` + +Name of the project in the form `/` providing the Frontend assets. It +is used to lookup revisions and current deployments. + +* Require: **yes** +* Default: **–** + ## [`GitlabVcsProvider`](../../src/Vcs/GitlabVcsProvider.php) Using this VCS Provider, GitLab can be interacted with as the VCS for the requested Frontend assets. This is necessary, for example, to query an active deployment of the requested assets. +Interacting with assets on GitLab requires either a [project access token][1] of the +appropriate project or an access token with at least `read_api` scope. + It supports the following additional configuration: ### `base-url` @@ -31,3 +60,5 @@ current deployments. * Require: **yes** * Default: **–** + +[1]: https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html diff --git a/phpstan.neon b/phpstan.neon index 1ddfb64..0aea627 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,5 +12,7 @@ parameters: symfony: consoleApplicationLoader: tests/Build/console-application.php containerXmlPath: var/cache/container_test.xml + stubFiles: + - tests/Build/Stubs/GraphQL/QueryBuilder.stub scanFiles: - var/cache/container_test.php diff --git a/src/Asset/Environment/Map/Map.php b/src/Asset/Environment/Map/Map.php index 0da67b3..70c4ada 100644 --- a/src/Asset/Environment/Map/Map.php +++ b/src/Asset/Environment/Map/Map.php @@ -26,6 +26,9 @@ use IteratorAggregate; use Traversable; +use function array_values; +use function ksort; + /** * Map. * @@ -62,7 +65,9 @@ public function merge(self $map): self $mergedMap = array_replace($this->pairs, $otherPairs); - return new self($mergedMap); + ksort($mergedMap); + + return new self(array_values($mergedMap)); } /** diff --git a/src/Command/ConfigAssetsCommand.php b/src/Command/ConfigAssetsCommand.php index 231f474..3ee5048 100644 --- a/src/Command/ConfigAssetsCommand.php +++ b/src/Command/ConfigAssetsCommand.php @@ -35,6 +35,7 @@ use Symfony\Component\Console; use function count; +use function explode; /** * ConfigAssetsCommand. @@ -302,7 +303,7 @@ private function buildAndValidatePath(array $assetDefinitions, string $path): st private function decoratePath(string $path): string { - $pathSegments = array_map(fn (string $segment): string => sprintf('[%s]', $segment), str_getcsv($path, '/')); + $pathSegments = array_map(fn (string $segment): string => sprintf('[%s]', $segment), explode('/', $path)); return implode('', $pathSegments); } diff --git a/src/Config/Initialization/Step/VcsConfigStep.php b/src/Config/Initialization/Step/VcsConfigStep.php index 662afe9..303f77b 100644 --- a/src/Config/Initialization/Step/VcsConfigStep.php +++ b/src/Config/Initialization/Step/VcsConfigStep.php @@ -93,32 +93,15 @@ public function execute(Config\Initialization\InitializationRequest $request): b ); $request->setOption('vcs-type', $vcsType); - // Additional variables for GitlabVcsProvider only - if (Vcs\GitlabVcsProvider::getName() === $vcsType) { - $this->askForAdditionalVariable( - $request, - 'Base URL', - 'base-url', - $additionalVariables, - 'https://gitlab.com', - ['notEmpty', 'url'], - ); - - $this->askForAdditionalVariable( - $request, - 'Access token', - 'access-token', - $additionalVariables, - validator: 'notEmpty', - ); - - $this->askForAdditionalVariable( - $request, - 'Project ID', - 'project-id', - $additionalVariables, - validator: 'integer', - ); + // Additional variables for specific providers + switch ($vcsType) { + case Vcs\GitlabVcsProvider::getName(): + $this->requestAdditionalVariablesForGitlabVcsProvider($request, $additionalVariables); + break; + + case Vcs\GithubVcsProvider::getName(): + $this->requestAdditionalVariablesForGithubVcsProvider($request, $additionalVariables); + break; } // VCS config extra @@ -147,6 +130,63 @@ public function execute(Config\Initialization\InitializationRequest $request): b return true; } + /** + * @param array $additionalVariables + */ + private function requestAdditionalVariablesForGitlabVcsProvider( + Config\Initialization\InitializationRequest $request, + array &$additionalVariables, + ): void { + $this->askForAdditionalVariable( + $request, + 'Base URL', + 'base-url', + $additionalVariables, + 'https://gitlab.com', + ['notEmpty', 'url'], + ); + + $this->askForAdditionalVariable( + $request, + 'Access token', + 'access-token', + $additionalVariables, + validator: 'notEmpty', + ); + + $this->askForAdditionalVariable( + $request, + 'Project ID', + 'project-id', + $additionalVariables, + validator: 'integer', + ); + } + + /** + * @param array $additionalVariables + */ + private function requestAdditionalVariablesForGithubVcsProvider( + Config\Initialization\InitializationRequest $request, + array &$additionalVariables, + ): void { + $this->askForAdditionalVariable( + $request, + 'Access token', + 'access-token', + $additionalVariables, + validator: 'notEmpty', + ); + + $this->askForAdditionalVariable( + $request, + 'Repository (/)', + 'repository', + $additionalVariables, + validator: 'notEmpty', + ); + } + private function buildVcs(Config\Initialization\InitializationRequest $request): void { $config = $request->getConfig(); diff --git a/src/Helper/ArrayHelper.php b/src/Helper/ArrayHelper.php index a08ed7e..66a0061 100644 --- a/src/Helper/ArrayHelper.php +++ b/src/Helper/ArrayHelper.php @@ -26,6 +26,7 @@ use CPSIT\FrontendAssetHandler\Exception; use function array_key_exists; +use function explode; use function is_array; /** @@ -71,7 +72,7 @@ public static function getArrayValueByPath(array $array, string $path) // Assure required structure in array $currentPathSegment = []; - foreach (str_getcsv($path, '/') as $segment) { + foreach (explode('/', $path) as $segment) { $currentPathSegment[] = $segment; if (is_array($value) && array_key_exists($segment, $value)) { $value = $value[$segment]; diff --git a/src/Vcs/GithubVcsProvider.php b/src/Vcs/GithubVcsProvider.php new file mode 100644 index 0000000..b474e86 --- /dev/null +++ b/src/Vcs/GithubVcsProvider.php @@ -0,0 +1,266 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace CPSIT\FrontendAssetHandler\Vcs; + +use CPSIT\FrontendAssetHandler\Asset; +use CPSIT\FrontendAssetHandler\Exception; +use CPSIT\FrontendAssetHandler\Helper; +use CPSIT\FrontendAssetHandler\Traits; +use GraphQL\Client; +use GraphQL\QueryBuilder; +use GraphQL\RawObject; +use GraphQL\Results; +use GraphQL\Util; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Psr7; + +use function explode; +use function in_array; +use function is_array; + +/** + * GithubVcsProvider. + * + * @author Elias Häußler + * @license GPL-3.0-or-later + */ +final class GithubVcsProvider implements DeployableVcsProviderInterface +{ + use Traits\DefaultConfigurationAwareTrait; + + private const API_URL = 'https://api.github.com/graphql'; + private const SUCCESSFUL_DEPLOYMENT_STATUS = 'SUCCESS'; + private const ACTIVE_DEPLOYMENT_STATUSES = [ + 'PENDING', + 'QUEUED', + 'IN_PROGRESS', + 'WAITING', + ]; + + private const DEFAULT_CONFIGURATION = [ + 'access-token' => null, + 'repository' => null, + ]; + + private ?Client $graphQlClient = null; + + public function __construct( + private readonly ClientInterface $client, + private ?string $accessToken = null, + // @todo Remove annotations once https://github.com/rectorphp/rector/issues/7568 is resolved + /** @noRector \Rector\Php81\Rector\Property\ReadOnlyPropertyRector */ + private ?string $owner = null, + /** @noRector \Rector\Php81\Rector\Property\ReadOnlyPropertyRector */ + private ?string $name = null, + private ?string $environment = null, + ) { + } + + public function withVcs(Asset\Definition\Vcs $vcs): static + { + // Validate and merge VCS configuration + $this->validateAssetDefinition($vcs); + $this->applyDefaultConfiguration($vcs); + + // Apply VCS configuration + $clone = clone $this; + $clone->graphQlClient = null; + $clone->accessToken = (string) $vcs['access-token']; + [$clone->owner, $clone->name] = explode('/', (string) $vcs['repository'], 2); + $clone->environment = $vcs->getEnvironment(); + + return $clone; + } + + public static function getName(): string + { + return 'github'; + } + + public function getSourceUrl(): string + { + $results = $this->sendRequest( + $this->createQueryBuilder()->selectField('url'), + ); + + return Helper\ArrayHelper::getArrayValueByPath( + $this->parseGraphQLData($results), + 'repository/url', + ); + } + + public function getLatestRevision(string $environment = null): ?Asset\Revision\Revision + { + try { + $results = $this->sendRequest( + $this->createQueryBuilder()->selectField( + (new QueryBuilder\QueryBuilder('deployments')) + ->setArgument('environments', $environment ?? $this->environment) + ->setArgument('first', 30) + ->setArgument('orderBy', new RawObject('{field:CREATED_AT, direction:DESC}')) + ->selectField( + (new QueryBuilder\QueryBuilder('nodes')) + ->selectField('commitOid') + ->selectField( + (new QueryBuilder\QueryBuilder('latestStatus')) + ->selectField('state') + ) + ) + ), + ); + + $nodes = Helper\ArrayHelper::getArrayValueByPath( + $this->parseGraphQLData($results), + 'repository/deployments/nodes', + ); + } catch (\Exception) { + return null; + } + + // Find latest successful deployment + foreach ($nodes as $node) { + $state = $node['latestStatus']['state'] ?? null; + + if (self::SUCCESSFUL_DEPLOYMENT_STATUS === $state) { + return new Asset\Revision\Revision($node['commitOid']); + } + } + + return null; + } + + public function hasRevision(Asset\Revision\Revision $revision): bool + { + try { + $results = $this->sendRequest( + $this->createQueryBuilder()->selectField( + (new QueryBuilder\QueryBuilder('object')) + ->setArgument('oid', $revision->get()) + ->selectField('id') + ), + ); + + return null !== Helper\ArrayHelper::getArrayValueByPath( + $this->parseGraphQLData($results), + 'repository/object', + ); + } catch (\Exception) { + return false; + } + } + + public function getActiveDeployments(): array + { + $deployments = []; + + $results = $this->sendRequest( + $this->createQueryBuilder()->selectField( + (new QueryBuilder\QueryBuilder('deployments')) + ->setArgument('environments', $this->environment) + ->setArgument('first', 30) + ->setArgument('orderBy', new RawObject('{field:CREATED_AT, direction:DESC}')) + ->selectField( + (new QueryBuilder\QueryBuilder('nodes')) + ->selectField('commitOid') + ->selectField( + (new QueryBuilder\QueryBuilder('latestStatus')) + ->selectField('logUrl') + ->selectField('state') + ) + ) + ), + ); + + $nodes = Helper\ArrayHelper::getArrayValueByPath( + $this->parseGraphQLData($results), + 'repository/deployments/nodes', + ); + + foreach ($nodes as $node) { + $state = $node['latestStatus']['state'] ?? null; + + if (in_array($state, self::ACTIVE_DEPLOYMENT_STATUSES, true)) { + $deployments[] = new Dto\Deployment( + new Psr7\Uri($node['latestStatus']['logUrl']), + new Asset\Revision\Revision($node['commitOid']), + ); + } + } + + return $deployments; + } + + private function createQueryBuilder(): QueryBuilder\QueryBuilder + { + return (new QueryBuilder\QueryBuilder('repository')) + ->setArgument('owner', $this->owner) + ->setArgument('name', $this->name) + ; + } + + private function sendRequest(QueryBuilder\QueryBuilder $queryBuilder): Results + { + return $this->getClient()->runQuery( + (new QueryBuilder\QueryBuilder())->selectField($queryBuilder), + true, + ); + } + + /** + * @return array + * + * @throws Exception\InvalidResponseException + */ + private function parseGraphQLData(Results $results): array + { + $data = $results->getData(); + + if (!is_array($data)) { + throw Exception\InvalidResponseException::create($results->getResponseBody()); + } + + return $data; + } + + private function getClient(): Client + { + if (null === $this->graphQlClient) { + $this->graphQlClient = new Client( + self::API_URL, + ['Authorization' => 'Bearer '.$this->accessToken], + httpClient: new Util\GuzzleAdapter($this->client), + ); + } + + return $this->graphQlClient; + } + + /** + * @return array{access-token: string|null, repository: string|null} + */ + protected function getDefaultConfiguration(): array + { + return self::DEFAULT_CONFIGURATION; + } +} diff --git a/src/Vcs/GitlabVcsProvider.php b/src/Vcs/GitlabVcsProvider.php index 6078ab3..adc3a4b 100644 --- a/src/Vcs/GitlabVcsProvider.php +++ b/src/Vcs/GitlabVcsProvider.php @@ -59,14 +59,15 @@ final class GitlabVcsProvider implements DeployableVcsProviderInterface ]; private Message\UriInterface $baseUrl; - private ?string $accessToken = null; - private ?int $projectId = null; - private ?string $environment = null; public function __construct( private readonly ClientInterface $client, + string $baseUrl = null, + private ?string $accessToken = null, + private ?int $projectId = null, + private ?string $environment = null, ) { - $this->createBaseUrl(self::DEFAULT_BASE_URL); + $this->baseUrl = $this->createBaseUrl($baseUrl ?? self::DEFAULT_BASE_URL); } /** diff --git a/tests/Build/Stubs/GraphQL/QueryBuilder.stub b/tests/Build/Stubs/GraphQL/QueryBuilder.stub new file mode 100644 index 0000000..9e21362 --- /dev/null +++ b/tests/Build/Stubs/GraphQL/QueryBuilder.stub @@ -0,0 +1,24 @@ + $pairs[1], ]; + /* @phpstan-ignore-next-line */ $subject = new Map($pairs); self::assertSame($expected, $subject->getPairs()); diff --git a/tests/Unit/Config/Initialization/Step/VcsConfigStepTest.php b/tests/Unit/Config/Initialization/Step/VcsConfigStepTest.php index b00fb57..c70a0b1 100644 --- a/tests/Unit/Config/Initialization/Step/VcsConfigStepTest.php +++ b/tests/Unit/Config/Initialization/Step/VcsConfigStepTest.php @@ -184,6 +184,50 @@ public function executeAsksForProjectIdForVcsTypeGitlab(): void self::assertStringContainsString('Project ID', $output); } + /** + * @test + */ + public function executeAsksForAccessTokenForVcsTypeGithub(): void + { + $input = $this->request->getInput(); + + self::assertInstanceOf(Console\Input\StreamableInputInterface::class, $input); + + self::setInputs(['yes', Vcs\GithubVcsProvider::getName(), 'foo', 'foo/baz', ''], $input); + + self::assertTrue($this->subject->execute($this->request)); + self::assertSame( + 'foo', + $this->request->getConfig()['frontend-assets'][0]['vcs']['access-token'], + ); + + $output = $this->output->fetch(); + + self::assertStringContainsString('Access token', $output); + } + + /** + * @test + */ + public function executeAsksForRepositoryForVcsTypeGithub(): void + { + $input = $this->request->getInput(); + + self::assertInstanceOf(Console\Input\StreamableInputInterface::class, $input); + + self::setInputs(['yes', Vcs\GithubVcsProvider::getName(), 'foo', 'foo/baz', ''], $input); + + self::assertTrue($this->subject->execute($this->request)); + self::assertSame( + 'foo/baz', + $this->request->getConfig()['frontend-assets'][0]['vcs']['repository'], + ); + + $output = $this->output->fetch(); + + self::assertStringContainsString('Repository (/)', $output); + } + /** * @test */ diff --git a/tests/Unit/Vcs/GithubVcsProviderTest.php b/tests/Unit/Vcs/GithubVcsProviderTest.php new file mode 100644 index 0000000..5f1c0a6 --- /dev/null +++ b/tests/Unit/Vcs/GithubVcsProviderTest.php @@ -0,0 +1,291 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace CPSIT\FrontendAssetHandler\Tests\Unit\Vcs; + +use CPSIT\FrontendAssetHandler\Asset; +use CPSIT\FrontendAssetHandler\Exception; +use CPSIT\FrontendAssetHandler\Tests; +use CPSIT\FrontendAssetHandler\Vcs; +use Generator; +use GuzzleHttp\Exception as GuzzleException; +use GuzzleHttp\Psr7; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message; +use Throwable; + +/** + * GithubVcsProviderTest. + * + * @author Elias Häußler + * @license GPL-3.0-or-later + */ +final class GithubVcsProviderTest extends TestCase +{ + use Tests\Unit\ClientMockTrait; + + private Asset\Definition\Vcs $vcs; + private Vcs\GithubVcsProvider $subject; + + protected function setUp(): void + { + $this->vcs = new Asset\Definition\Vcs([ + 'type' => Vcs\GithubVcsProvider::getName(), + 'access-token' => 'foo', + 'repository' => 'foo/baz', + 'environment' => 'baz', + ]); + $this->subject = new Vcs\GithubVcsProvider($this->getPreparedClient()); + } + + /** + * @test + */ + public function withVcsThrowsExceptionIfAccessTokenIsMissing(): void + { + unset($this->vcs['access-token']); + + $this->expectException(Exception\MissingConfigurationException::class); + $this->expectExceptionCode(1623867663); + $this->expectExceptionMessage('Configuration for key "access-token" is missing or invalid.'); + + $this->subject->withVcs($this->vcs); + } + + /** + * @test + */ + public function withVcsThrowsExceptionIfRepositoryIsMissing(): void + { + unset($this->vcs['repository']); + + $this->expectException(Exception\MissingConfigurationException::class); + $this->expectExceptionCode(1623867663); + $this->expectExceptionMessage('Configuration for key "repository" is missing or invalid.'); + + $this->subject->withVcs($this->vcs); + } + + /** + * @test + */ + public function withVcsReturnsClonedInstance(): void + { + $actual = $this->subject->withVcs($this->vcs); + + self::assertInstanceOf(Vcs\GithubVcsProvider::class, $actual); + } + + /** + * @test + */ + public function getSourceUrlThrowsExceptionIfResponseIsUnexpected(): void + { + $this->mockHandler->append($response = new Psr7\Response()); + + $response->getBody()->write('{"data":"baz"}'); + $response->getBody()->rewind(); + + $this->expectExceptionObject(Exception\InvalidResponseException::create('{"data":"baz"}')); + + $this->subject->withVcs($this->vcs)->getSourceUrl(); + } + + /** + * @test + */ + public function getSourceUrlReturnsSourceUrl(): void + { + $this->mockHandler->append($response = new Psr7\Response()); + + $response->getBody()->write('{"data":{"repository":{"url":"foo"}}}'); + $response->getBody()->rewind(); + + self::assertSame('foo', $this->subject->withVcs($this->vcs)->getSourceUrl()); + } + + /** + * @test + */ + public function getLatestRevisionReturnsNullIfApiResponseIsUnexpected(): void + { + $this->mockHandler->append(new GuzzleException\TransferException()); + + self::assertNull($this->subject->withVcs($this->vcs)->getLatestRevision()); + } + + /** + * @test + */ + public function getLatestRevisionReturnsNullIfApiResponseIsInvalid(): void + { + $this->mockHandler->append($response = new Psr7\Response()); + + $response->getBody()->write('{"data":"foo"}'); + $response->getBody()->rewind(); + + self::assertNull($this->subject->withVcs($this->vcs)->getLatestRevision()); + } + + /** + * @test + */ + public function getLatestRevisionReturnsRevisionForPreConfiguredEnvironment(): void + { + $this->mockHandler->append($response = new Psr7\Response()); + + $response->getBody()->write('{"data":{"repository":{"deployments":{"nodes":[{"latestStatus":{"state":"SUCCESS"},"commitOid":"1234567890"}]}}}}'); + $response->getBody()->rewind(); + + $expected = new Asset\Revision\Revision('1234567890'); + + self::assertEquals($expected, $this->subject->withVcs($this->vcs)->getLatestRevision()); + self::assertStringContainsString('environments: \\"baz\\"', (string) $this->getLastRequest()->getBody()); + } + + /** + * @test + */ + public function getLatestRevisionReturnsRevisionForGivenEnvironment(): void + { + $this->mockHandler->append($response = new Psr7\Response()); + + $response->getBody()->write('{"data":{"repository":{"deployments":{"nodes":[{"latestStatus":{"state":"SUCCESS"},"commitOid":"1234567890"}]}}}}'); + $response->getBody()->rewind(); + + $expected = new Asset\Revision\Revision('1234567890'); + + self::assertEquals($expected, $this->subject->withVcs($this->vcs)->getLatestRevision('foo')); + self::assertStringContainsString('environments: \\"foo\\"', (string) $this->getLastRequest()->getBody()); + } + + /** + * @test + */ + public function getLatestRevisionReturnsNullIfNoSuccessfulDeploymentsAreAvailable(): void + { + $this->mockHandler->append($response = new Psr7\Response()); + + $response->getBody()->write('{"data":{"repository":{"deployments":{"nodes":[]}}}}'); + $response->getBody()->rewind(); + + self::assertNull($this->subject->withVcs($this->vcs)->getLatestRevision()); + } + + /** + * @test + * + * @dataProvider hasRevisionReturnsTrueIfRevisionExistsInVcsDataProvider + */ + public function hasRevisionReturnsTrueIfRevisionExistsInVcs( + Message\ResponseInterface|Throwable $response, + bool $expected, + ): void { + $this->mockHandler->append($response); + + $revision = new Asset\Revision\Revision('1234567890'); + + self::assertSame($expected, $this->subject->withVcs($this->vcs)->hasRevision($revision)); + self::assertStringContainsString( + 'object(oid: \\"1234567890\\")', + (string) $this->getLastRequest()->getBody(), + ); + } + + /** + * @test + * + * @dataProvider getActiveDeploymentsReturnsActiveDeploymentsIfAnyPipelinesAreActiveDataProvider + * + * @param list $expected + */ + public function getActiveDeploymentsReturnsActiveDeploymentsIfAnyPipelinesAreActive( + Message\ResponseInterface $response, + array $expected, + ): void { + $this->mockHandler->append($response); + + self::assertEquals($expected, $this->subject->withVcs($this->vcs)->getActiveDeployments()); + } + + /** + * @return Generator + */ + public function hasRevisionReturnsTrueIfRevisionExistsInVcsDataProvider(): Generator + { + $response = new Psr7\Response(); + $response->getBody()->write('{"data":{"repository":{"object":"foo"}}}'); + $response->getBody()->rewind(); + + yield 'exception' => [new GuzzleException\TransferException(), false]; + yield 'unexpected response' => [$response->withStatus(404), false]; + yield 'valid response' => [$response, true]; + } + + /** + * @return \Generator}> + */ + public function getActiveDeploymentsReturnsActiveDeploymentsIfAnyPipelinesAreActiveDataProvider(): Generator + { + $uri = new Psr7\Uri('https://www.example.com'); + $revision = new Asset\Revision\Revision('1234567890'); + $deployment = new Vcs\Dto\Deployment($uri, $revision); + + /** + * @param list $statuses + */ + $createResponse = function (array $statuses) use ($deployment): Message\ResponseInterface { + $response = new Psr7\Response(); + $json = [ + 'data' => [ + 'repository' => [ + 'deployments' => [ + 'nodes' => array_map( + fn (string $status) => [ + 'latestStatus' => [ + 'state' => $status, + 'logUrl' => 'https://www.example.com', + ], + 'commitOid' => $deployment->getRevision()->get(), + ], + $statuses, + ), + ], + ], + ], + ]; + + $response->getBody()->write(json_encode($json, JSON_THROW_ON_ERROR)); + $response->getBody()->rewind(); + + return $response; + }; + + yield 'pending deployment' => [$createResponse(['PENDING']), [$deployment]]; + yield 'queued deployment' => [$createResponse(['QUEUED']), [$deployment]]; + yield 'active deployment' => [$createResponse(['IN_PROGRESS']), [$deployment]]; + yield 'waiting deployment' => [$createResponse(['WAITING']), [$deployment]]; + yield 'multiple deployments' => [$createResponse(['PENDING', 'QUEUED', 'IN_PROGRESS', 'WAITING']), [$deployment, $deployment, $deployment, $deployment]]; + yield 'no active deployments' => [$createResponse([]), []]; + } +}