From a618d1ea694bbb0e994a1e98167deee214eafcb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Nov 2022 08:50:33 +0000 Subject: [PATCH 01/11] [TASK]: Bump friendsofphp/php-cs-fixer from 3.12.0 to 3.13.0 Bumps [friendsofphp/php-cs-fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer) from 3.12.0 to 3.13.0. - [Release notes](https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases) - [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md) - [Commits](https://github.com/FriendsOfPHP/PHP-CS-Fixer/compare/v3.12.0...v3.13.0) --- updated-dependencies: - dependency-name: friendsofphp/php-cs-fixer dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- composer.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/composer.lock b/composer.lock index 3ef11eb..73e2dfb 100644 --- a/composer.lock +++ b/composer.lock @@ -2662,16 +2662,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 +2695,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 +2738,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 +2747,7 @@ "type": "github" } ], - "time": "2022-10-12T14:20:51+00:00" + "time": "2022-10-31T19:28:50+00:00" }, { "name": "idiosyncratic/editorconfig", From 6167440b9084265ac985b77d86fea2161a734647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Wed, 2 Nov 2022 10:06:47 +0100 Subject: [PATCH 02/11] [BUGFIX] Promote all properties in GitlabVcsProvider --- src/Vcs/GitlabVcsProvider.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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); } /** From 61f19d0ecdec8f0209f5ca660d5386f955339929 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 09:11:03 +0000 Subject: [PATCH 03/11] [TASK]: Bump phpstan/phpstan from 1.8.11 to 1.9.0 Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan) from 1.8.11 to 1.9.0. - [Release notes](https://github.com/phpstan/phpstan/releases) - [Changelog](https://github.com/phpstan/phpstan/blob/1.9.x/CHANGELOG.md) - [Commits](https://github.com/phpstan/phpstan/compare/1.8.11...1.9.0) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 73e2dfb..5280b74 100644 --- a/composer.lock +++ b/composer.lock @@ -3086,16 +3086,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 +3125,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 +3141,7 @@ "type": "tidelift" } ], - "time": "2022-10-24T15:45:13+00:00" + "time": "2022-11-03T07:26:48+00:00" }, { "name": "phpstan/phpstan-phpunit", From 5216a211f7d46de2c8cfa87ed9bd2dae7bbbd64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Thu, 3 Nov 2022 11:11:28 +0100 Subject: [PATCH 04/11] [TASK] Make codebase compatible with PHPStan 1.9.x --- src/Asset/Environment/Map/Map.php | 7 ++++++- src/Command/ConfigAssetsCommand.php | 3 ++- src/Helper/ArrayHelper.php | 3 ++- tests/Unit/Asset/Environment/Map/MapTest.php | 1 + 4 files changed, 11 insertions(+), 3 deletions(-) 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/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/tests/Unit/Asset/Environment/Map/MapTest.php b/tests/Unit/Asset/Environment/Map/MapTest.php index 6b48f95..2f86703 100644 --- a/tests/Unit/Asset/Environment/Map/MapTest.php +++ b/tests/Unit/Asset/Environment/Map/MapTest.php @@ -68,6 +68,7 @@ public function constructorSortsPairsByIndex(): void 1 => $pairs[1], ]; + /* @phpstan-ignore-next-line */ $subject = new Map($pairs); self::assertSame($expected, $subject->getPairs()); From 777b3cc354b32cea1c30338e814b614ed1f6b44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Fri, 16 Sep 2022 20:37:33 +0200 Subject: [PATCH 05/11] [FEATURE] Add support for GitHub as VCS provider --- composer.json | 1 + composer.lock | 67 ++++- docs/components/vcs-providers.md | 23 ++ phpstan.neon | 2 + src/Vcs/GithubVcsProvider.php | 259 +++++++++++++++++ tests/Build/Stubs/GraphQL/QueryBuilder.stub | 24 ++ tests/Unit/Vcs/GithubVcsProviderTest.php | 291 ++++++++++++++++++++ 7 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 src/Vcs/GithubVcsProvider.php create mode 100644 tests/Build/Stubs/GraphQL/QueryBuilder.stub create mode 100644 tests/Unit/Vcs/GithubVcsProviderTest.php 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 5280b74..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", diff --git a/docs/components/vcs-providers.md b/docs/components/vcs-providers.md index 312fbac..5ff2a2a 100644 --- a/docs/components/vcs-providers.md +++ b/docs/components/vcs-providers.md @@ -1,5 +1,28 @@ # VCS Providers +## [`GithubVcsProvider`](../../src/Vcs/GithubVcsProvider.php) + +There exists a VCS provider to support Frontend assets hosted on github.com. For +this, an access token with `repo` scope is required. + +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 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/Vcs/GithubVcsProvider.php b/src/Vcs/GithubVcsProvider.php new file mode 100644 index 0000000..05bc8d4 --- /dev/null +++ b/src/Vcs/GithubVcsProvider.php @@ -0,0 +1,259 @@ + + * + * 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, + private ?string $owner = null, + 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) { + if (self::SUCCESSFUL_DEPLOYMENT_STATUS === $node['latestStatus']['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) { + if (in_array($node['latestStatus']['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/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 @@ + + * + * 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([]), []]; + } +} From 9f03ae874ead18a3bdf193baa225343b244bddf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Wed, 2 Nov 2022 11:31:36 +0100 Subject: [PATCH 06/11] [BUGFIX] Disable invalid behavior of ReadOnlyPropertyRector Can be reverted once https://github.com/rectorphp/rector/issues/7568 is resolved. --- src/Vcs/GithubVcsProvider.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Vcs/GithubVcsProvider.php b/src/Vcs/GithubVcsProvider.php index 05bc8d4..76cb3c8 100644 --- a/src/Vcs/GithubVcsProvider.php +++ b/src/Vcs/GithubVcsProvider.php @@ -68,7 +68,10 @@ final class GithubVcsProvider implements DeployableVcsProviderInterface 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, ) { From 6f17b3f7afcd40a32a743dd601f907a64de6189d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Thu, 3 Nov 2022 08:19:24 +0100 Subject: [PATCH 07/11] [TASK] Ask for additional variables on initializing GitHub VCS provider --- .../Initialization/Step/VcsConfigStep.php | 92 +++++++++++++------ .../Initialization/Step/VcsConfigStepTest.php | 44 +++++++++ 2 files changed, 110 insertions(+), 26 deletions(-) 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/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 */ From 5455408d66373b42408cbb66e628bb9acf75f56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Thu, 3 Nov 2022 10:39:53 +0100 Subject: [PATCH 08/11] [BUGFIX] Harden check for latest deployment status --- src/Vcs/GithubVcsProvider.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Vcs/GithubVcsProvider.php b/src/Vcs/GithubVcsProvider.php index 76cb3c8..b474e86 100644 --- a/src/Vcs/GithubVcsProvider.php +++ b/src/Vcs/GithubVcsProvider.php @@ -140,7 +140,9 @@ public function getLatestRevision(string $environment = null): ?Asset\Revision\R // Find latest successful deployment foreach ($nodes as $node) { - if (self::SUCCESSFUL_DEPLOYMENT_STATUS === $node['latestStatus']['state']) { + $state = $node['latestStatus']['state'] ?? null; + + if (self::SUCCESSFUL_DEPLOYMENT_STATUS === $state) { return new Asset\Revision\Revision($node['commitOid']); } } @@ -196,7 +198,9 @@ public function getActiveDeployments(): array ); foreach ($nodes as $node) { - if (in_array($node['latestStatus']['state'], self::ACTIVE_DEPLOYMENT_STATUSES, true)) { + $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']), From 183b8a5e27222c0d13f035be6ab995853ca5b148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Thu, 3 Nov 2022 10:49:19 +0100 Subject: [PATCH 09/11] [DOCS] Improve documentation about VCS providers Co-authored-by: Martin Adler --- docs/components/vcs-providers.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/components/vcs-providers.md b/docs/components/vcs-providers.md index 5ff2a2a..6a88c78 100644 --- a/docs/components/vcs-providers.md +++ b/docs/components/vcs-providers.md @@ -1,9 +1,12 @@ # 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) -There exists a VCS provider to support Frontend assets hosted on github.com. For -this, an access token with `repo` scope is required. +Interacting with assets on GitHub requires an access token with `repo` scope. It supports the following additional configuration: From 20ad80d93f23131c464a380360b3a43f10c52361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Thu, 3 Nov 2022 11:23:54 +0100 Subject: [PATCH 10/11] [DOCS] Mention required scope for GitLab access tokens --- docs/components/vcs-providers.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/components/vcs-providers.md b/docs/components/vcs-providers.md index 6a88c78..87feaa8 100644 --- a/docs/components/vcs-providers.md +++ b/docs/components/vcs-providers.md @@ -32,6 +32,9 @@ Using this VCS Provider, GitLab can be interacted with as the VCS for the reques 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` @@ -57,3 +60,5 @@ current deployments. * Require: **yes** * Default: **–** + +[1]: https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html From 16ccdf0183fab132ba5b769646941eae3e4039f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Thu, 3 Nov 2022 11:29:44 +0100 Subject: [PATCH 11/11] [RELEASE] Release of frontend-asset-handler 1.3.0 --- ChangeLog | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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)