From 9495a72ccb972245d15cef08d81004cdfc6b6ac2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 29 Jul 2026 19:56:59 +0530 Subject: [PATCH 01/20] Report a malformed GitLab webhook payload instead of returning nothing GitHub and Gitea both throw on a payload that isn't valid JSON, while GitLab returned an empty array - indistinguishable from an event it simply doesn't handle. That inconsistency was the only reason the invalid-payload test had to live in the Gitea class rather than the shared base. Unsupported event names still return an empty array, as before. --- src/VCS/Adapter/Git/GitLab.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 6c97a2ab..fb4f1646 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -1060,7 +1060,7 @@ public function getEvent(string $event, string $payload): array { $payloadArray = json_decode($payload, true); if ($payloadArray === null || !is_array($payloadArray)) { - return []; + throw new Exception("Invalid payload."); } switch ($event) { From 7b3d4efe671d4c4bdf714277e699ee293ca36e21 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 29 Jul 2026 19:56:59 +0530 Subject: [PATCH 02/20] Assert per-provider facts instead of accepting either shape Base tests papered over provider differences with fallbacks, so they passed without pinning anything down: owner was read as owner.login or namespace.path or bust, visibility as a bool or a string, a pull request number as iid or number or 0, a user handle as username or login or empty, pushed_at as null or a timestamp, and a pull request state as open or opened. Each of those is now a declared per-provider fact - an overridable helper or a static - so every adapter asserts exactly what its provider reports and a wrong shape fails instead of falling through. The same declarations let these move into Base, which is where the duplication actually was: - webhook header names, which also replaces two Base tests that only checked the names were non-empty - getOwnerName with a missing, zero and null repository id - createRepository with an invalid name, and clone commands for a tag - searchRepositories matching by name, now also covering GitHub - getEvent on a malformed payload, now also covering GitLab Tests defined in more than one adapter class: 13 -> 8. The rest are genuinely provider specific - webhook payloads, signature schemes, presigned URL shapes. --- tests/VCS/Adapter/GitHubTest.php | 33 ++++- tests/VCS/Adapter/GitLabTest.php | 106 ++++++--------- tests/VCS/Adapter/GiteaTest.php | 89 +------------ tests/VCS/Base.php | 219 +++++++++++++++++++++++-------- 4 files changed, 237 insertions(+), 210 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index e6b69ffb..46c42a56 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -14,6 +14,9 @@ class GitHubTest extends Base protected static string $owner = ''; protected static string $installationId = ''; protected static string $defaultBranch = 'main'; + protected static bool $reportsPushedAtOnEmptyRepository = false; + protected static string $eventHeader = 'x-github-event'; + protected static string $signatureHeader = 'x-hub-signature-256'; protected function setupAdapter(): void { @@ -41,11 +44,6 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - public function testWebhookHeaderNames(): void - { - $this->assertSame('x-github-event', $this->vcsAdapter->getEventHeaderName()); - $this->assertSame('x-hub-signature-256', $this->vcsAdapter->getSignatureHeaderName()); - } public function testGetEventPush(): void { @@ -668,6 +666,31 @@ public function testListTags(): void $this->markTestSkipped('createTag() is not implemented for GitHub'); } + public function testGenerateCloneCommandWithTag(): void + { + $this->markTestSkipped('createTag() is not implemented for GitHub'); + } + + public function testCreateRepositoryWithInvalidName(): void + { + $this->markTestSkipped('GitHub normalizes spaces in repository names instead of rejecting them'); + } + + public function testGetOwnerNameWithoutRepositoryId(): void + { + $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); + } + + public function testGetOwnerNameWithZeroRepositoryId(): void + { + $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); + } + + public function testGetOwnerNameWithNullRepositoryId(): void + { + $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); + } + public function testListRepositoryLanguages(): void { $repositoryName = 'test-list-repository-languages-' . \uniqid(); diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 20862600..ee3913d6 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -13,6 +13,9 @@ class GitLabTest extends Base protected static string $accessToken = ''; protected static string $owner = ''; protected static string $defaultBranch = 'main'; + protected static string $openPullRequestState = 'opened'; + protected static string $eventHeader = 'x-gitlab-event'; + protected static string $signatureHeader = 'x-gitlab-token'; protected function setupAdapter(): void { @@ -55,12 +58,47 @@ protected function ownerPath(): string return \explode(':', static::$owner)[1] ?? static::$owner; } - public function testWebhookHeaderNames(): void + /** + * GitLab reports a project's owner as its namespace. + * + * @param array $repository + */ + protected function ownerOf(array $repository): string + { + $this->assertArrayHasKey('namespace', $repository); + $this->assertIsArray($repository['namespace']); + $this->assertArrayHasKey('path', $repository['namespace']); + + return (string) $repository['namespace']['path']; + } + + /** + * GitLab reports visibility as a string rather than a boolean flag. + * + * @param array $repository + */ + protected function isPrivate(array $repository): bool + { + $this->assertArrayHasKey('visibility', $repository); + $this->assertIsString($repository['visibility']); + + return $repository['visibility'] === 'private'; + } + + /** + * GitLab numbers merge requests per project, under 'iid'. + * + * @param array $pullRequest + */ + protected function pullRequestNumberOf(array $pullRequest): int { - $this->assertSame('x-gitlab-event', $this->vcsAdapter->getEventHeaderName()); - $this->assertSame('x-gitlab-token', $this->vcsAdapter->getSignatureHeaderName()); + $this->assertArrayHasKey('iid', $pullRequest); + $this->assertIsNumeric($pullRequest['iid']); + + return (int) $pullRequest['iid']; } + public function testListTagsCommitlessRepository(): void { $repositoryName = 'test-list-tags-commitless-' . \uniqid(); @@ -87,25 +125,6 @@ protected function setupGitLab(): void } - public function testSearchRepositoriesWithSearch(): void - { - $uniqueId = \uniqid(); - $repositoryName = 'test-search-unique-' . $uniqueId; - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10, $uniqueId); - - $this->assertIsArray($result); - $this->assertArrayHasKey('items', $result); - $this->assertNotEmpty($result['items']); - - $names = array_column($result['items'], 'name'); - $this->assertContains($repositoryName, $names); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } public function testGetCommitStatuses(): void { @@ -162,36 +181,6 @@ public function testGetCommitStatusesEmptyForNewCommit(): void } } - public function testGenerateCloneCommandWithTag(): void - { - $repositoryName = 'test-clone-tag-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['commitHash']; - - $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash); - - $directory = '/tmp/test-clone-tag-' . \uniqid(); - $command = $this->vcsAdapter->generateCloneCommand( - static::$owner, - $repositoryName, - 'v1.0.0', - \Utopia\VCS\Adapter\Git::CLONE_TYPE_TAG, - $directory, - '/' - ); - - $this->assertIsString($command); - $this->assertStringContainsString('refs/tags', $command); - $this->assertStringContainsString('v1.0.0', $command); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } public function testValidateWebhookEvent(): void { @@ -457,21 +446,8 @@ public function testGetEventPushMatchesCheckoutSha(): void $this->assertSame('http://example.com/commit/def456', $result['headCommitUrl']); } - public function testCreateRepositoryWithInvalidName(): void - { - $this->expectException(\Exception::class); - $this->vcsAdapter->createRepository(static::$owner, 'invalid name with spaces', false); - } - public function testGetOwnerNameWithoutRepositoryId(): void - { - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('')); - } - public function testGetOwnerNameWithZeroRepositoryId(): void - { - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', 0)); - } public function testGetEventPushDetectsBranchCreated(): void { diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 0a887940..280e7f2e 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -2,12 +2,10 @@ namespace Utopia\Tests\Adapter; -use Exception; use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; use Utopia\System\System; use Utopia\Tests\Base; -use Utopia\VCS\Adapter\Git; use Utopia\VCS\Adapter\Git\Gitea; use Utopia\VCS\Exception\RepositoryNotFound; @@ -17,9 +15,10 @@ class GiteaTest extends Base protected static string $owner = ''; protected static string $defaultBranch = 'main'; protected static string $existingUser = 'utopia'; - protected static string $avatarDomain = 'gravatar.com'; + protected static string $userHandleField = 'login'; protected static string $eventHeader = 'x-gitea-event'; protected static string $signatureHeader = 'x-gitea-signature'; + protected static string $avatarDomain = 'gravatar.com'; protected function setupAdapter(): void { @@ -95,11 +94,6 @@ public function testGetRepositoryAfterDeleteFails(): void } - public function testWebhookHeaderNames(): void - { - $this->assertSame(static::$eventHeader, $this->vcsAdapter->getEventHeaderName()); - $this->assertSame(static::$signatureHeader, $this->vcsAdapter->getSignatureHeaderName()); - } public function testGetCommitAuthorAvatar(): void { @@ -156,42 +150,6 @@ public function testGetRepositoryName(): void $this->assertTrue($this->vcsAdapter->deleteRepository(static::$owner, $repositoryName)); } - public function testGenerateCloneCommandWithTag(): void - { - $repositoryName = 'test-clone-tag-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - // Create initial file and get commit hash - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test Tag'); - - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['commitHash']; - - // Create a tag - $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash, 'Release v1.0.0'); - - $command = $this->vcsAdapter->generateCloneCommand( - static::$owner, - $repositoryName, - 'v1.0.0', - Git::CLONE_TYPE_TAG, - '/tmp/test-clone-tag-' . \uniqid(), - '/' - ); - - // Verify the command contains tag-specific git commands - $this->assertIsString($command); - $this->assertStringContainsString('git init', $command); - $this->assertStringContainsString('git remote add origin', $command); - $this->assertStringContainsString('git config core.sparseCheckout true', $command); - $this->assertStringContainsString('refs/tags', $command); - $this->assertStringContainsString('v1.0.0', $command); - $this->assertStringContainsString('git checkout FETCH_HEAD', $command); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } public function testGetEventPush(): void { @@ -394,13 +352,6 @@ public function testValidateWebhookEventInvalid(): void $this->assertFalse($result); } - public function testGetEventInvalidPayload(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid payload'); - - $this->vcsAdapter->getEvent('push', 'invalid json'); - } public function testSearchRepositoriesPagination(): void { @@ -429,37 +380,10 @@ public function testSearchRepositoriesPagination(): void } } - public function testSearchRepositoriesMatchesName(): void - { - $match = 'test-search-match-' . \uniqid(); - $other = 'test-search-other-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $match, false); - $this->vcsAdapter->createRepository(static::$owner, $other, false); - try { - $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10, $match); - - $names = array_column($result['items'], 'name'); - $this->assertContains($match, $names); - $this->assertNotContains($other, $names); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $match); - $this->vcsAdapter->deleteRepository(static::$owner, $other); - } - } - public function testGetOwnerNameWithZeroRepositoryId(): void - { - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', 0)); - } - - public function testGetOwnerNameWithoutRepositoryId(): void - { - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('')); - } - public function testGetOwnerNameWithInvalidRepositoryId(): void { $this->expectException(RepositoryNotFound::class); @@ -467,10 +391,6 @@ public function testGetOwnerNameWithInvalidRepositoryId(): void $this->vcsAdapter->getOwnerName('', 999999999); } - public function testGetOwnerNameWithNullRepositoryId(): void - { - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', null)); - } public function testGetInstallationRepository(): void { @@ -625,10 +545,5 @@ public function testWebhookPullRequestEvent(): void } } - public function testCreateRepositoryWithInvalidName(): void - { - $this->expectException(Exception::class); - $this->vcsAdapter->createRepository(static::$owner, 'invalid name with spaces', false); - } } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index f00b280b..03a6e3c1 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -21,6 +21,29 @@ abstract class Base extends TestCase */ protected static string $existingUser = 'root'; + /** + * Field the provider reports a user's handle under. + */ + protected static string $userHandleField = 'username'; + + /** + * State the provider reports for a freshly opened pull request. + */ + protected static string $openPullRequestState = 'open'; + + /** + * Whether the provider reports a pushed_at timestamp for a repository + * that has no commits yet. GitHub reports null until the first push. + */ + protected static bool $reportsPushedAtOnEmptyRepository = true; + + /** + * Headers the provider sends its webhook event type and signature under. + */ + protected static string $eventHeader = ''; + + protected static string $signatureHeader = ''; + /** * Build the adapter under test and assign it to $this->vcsAdapter. */ @@ -89,41 +112,64 @@ protected function ownerPath(): string } /** - * Owner of a repository: 'owner.login' on GitHub and Gitea, 'namespace.path' on GitLab. + * Owner of a repository, as GitHub and Gitea report it. GitLab overrides this. * * @param array $repository */ protected function ownerOf(array $repository): string { - $owner = $repository['owner'] ?? []; - if (\is_array($owner) && !empty($owner['login'])) { - return (string) $owner['login']; - } + $this->assertArrayHasKey('owner', $repository); + $this->assertIsArray($repository['owner']); + $this->assertArrayHasKey('login', $repository['owner']); - $namespace = $repository['namespace'] ?? []; - if (\is_array($namespace) && !empty($namespace['path'])) { - return (string) $namespace['path']; - } - - $this->fail('Repository reports no owner'); + return (string) $repository['owner']['login']; } /** - * GitHub and Gitea report visibility as a 'private' flag, GitLab as a 'visibility' string. + * Visibility as GitHub and Gitea report it, a boolean flag. GitLab overrides this. * * @param array $repository */ protected function isPrivate(array $repository): bool { - if (\array_key_exists('private', $repository)) { - return $repository['private'] === true; - } + $this->assertArrayHasKey('private', $repository); + $this->assertIsBool($repository['private']); - if (\array_key_exists('visibility', $repository)) { - return $repository['visibility'] === 'private'; + return $repository['private']; + } + + /** + * Number of a pull request, as every provider but GitLab reports it. + * + * @param array $pullRequest + */ + protected function pullRequestNumberOf(array $pullRequest): int + { + $this->assertArrayHasKey('number', $pullRequest); + $this->assertIsNumeric($pullRequest['number']); + + return (int) $pullRequest['number']; + } + + /** + * A repository with no commits reports pushed_at differently per provider. + * + * @param array $repository + */ + protected function assertPushedAtOnEmptyRepository(array $repository): void + { + $this->assertArrayHasKey('pushed_at', $repository); + + if (!static::$reportsPushedAtOnEmptyRepository) { + $this->assertNull($repository['pushed_at']); + + return; } - $this->fail('Repository reports neither a private flag nor a visibility'); + $this->assertNotFalse( + \strtotime((string) $repository['pushed_at']), + 'pushed_at is not a parseable timestamp' + ); } protected function assertEventually(callable $probe, int $timeoutMs = 15000, int $waitMs = 500): void @@ -166,16 +212,10 @@ protected function deleteLastWebhookRequest(): void ); } - public function testGetEventHeaderName(): void + public function testWebhookHeaderNames(): void { - $this->assertIsString($this->vcsAdapter->getEventHeaderName()); - $this->assertNotEmpty($this->vcsAdapter->getEventHeaderName()); - } - - public function testGetSignatureHeaderName(): void - { - $this->assertIsString($this->vcsAdapter->getSignatureHeaderName()); - $this->assertNotEmpty($this->vcsAdapter->getSignatureHeaderName()); + $this->assertSame(static::$eventHeader, $this->vcsAdapter->getEventHeaderName()); + $this->assertSame(static::$signatureHeader, $this->vcsAdapter->getSignatureHeaderName()); } public function testGetSupportedWebhookScopes(): void @@ -195,14 +235,8 @@ public function testCreateRepository(): void $this->assertIsArray($result); $this->assertArrayHasKey('name', $result); $this->assertSame($repositoryName, $result['name']); - $this->assertArrayHasKey('pushed_at', $result); - // GitHub reports null until the first push; anything else must be a real timestamp - $this->assertTrue( - $result['pushed_at'] === null || \strtotime((string) $result['pushed_at']) !== false, - 'pushed_at is neither null nor a parseable timestamp' - ); + $this->assertPushedAtOnEmptyRepository($result); - // GitHub and Gitea report a 'private' flag, GitLab a 'visibility' string $this->assertFalse($this->isPrivate($result), 'createRepository() reported the new repository as private'); $this->assertSame($this->ownerPath(), $this->ownerOf($result)); @@ -243,10 +277,7 @@ public function testGetRepository(): void $this->assertIsArray($result); $this->assertSame($repositoryName, $result['name']); - $this->assertArrayHasKey('pushed_at', $result); - $this->assertTrue( - $result['pushed_at'] === null || \strtotime($result['pushed_at']) !== false - ); + $this->assertPushedAtOnEmptyRepository($result); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -305,7 +336,8 @@ public function testGetRepositoryName(): void try { $this->assertIsArray($created); $this->assertArrayHasKey('id', $created); - $repositoryId = (string) ($created['id'] ?? ''); + $this->assertIsNumeric($created['id']); + $repositoryId = (string) $created['id']; $result = $this->vcsAdapter->getRepositoryName($repositoryId); @@ -830,6 +862,21 @@ public function testGenerateCloneCommandWithInvalidRepository(): void } } + public function testGetOwnerNameWithoutRepositoryId(): void + { + $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('')); + } + + public function testGetOwnerNameWithZeroRepositoryId(): void + { + $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', 0)); + } + + public function testGetOwnerNameWithNullRepositoryId(): void + { + $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', null)); + } + public function testGetOwnerName(): void { $repositoryName = 'test-get-owner-name-' . \uniqid(); @@ -838,7 +885,8 @@ public function testGetOwnerName(): void try { $this->assertIsArray($created); $this->assertArrayHasKey('id', $created); - $repositoryId = (int) ($created['id'] ?? 0); + $this->assertIsNumeric($created['id']); + $repositoryId = (int) $created['id']; $this->assertSame($this->ownerPath(), $this->vcsAdapter->getOwnerName('', $repositoryId)); } finally { @@ -846,6 +894,68 @@ public function testGetOwnerName(): void } } + public function testCreateRepositoryWithInvalidName(): void + { + $this->expectException(Exception::class); + $this->vcsAdapter->createRepository(static::$owner, 'invalid name with spaces', false); + } + + public function testGenerateCloneCommandWithTag(): void + { + $repositoryName = 'test-clone-tag-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $directory = '/tmp/test-clone-tag-' . \uniqid(); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test Tag'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash, 'Release v1.0.0'); + + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + $repositoryName, + 'v1.0.0', + Git::CLONE_TYPE_TAG, + $directory, + '/' + ); + + $this->assertIsString($command); + $this->assertStringContainsString('git init', $command); + $this->assertStringContainsString('git remote add origin', $command); + $this->assertStringContainsString('git config core.sparseCheckout true', $command); + $this->assertStringContainsString('refs/tags', $command); + $this->assertStringContainsString('v1.0.0', $command); + $this->assertStringContainsString('git checkout FETCH_HEAD', $command); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testSearchRepositoriesMatchesName(): void + { + $match = 'test-search-match-' . \uniqid(); + $other = 'test-search-other-' . \uniqid(); + + $this->vcsAdapter->createRepository(static::$owner, $match, false); + $this->vcsAdapter->createRepository(static::$owner, $other, false); + + try { + $names = []; + $this->assertEventually(function () use (&$names, $match) { + $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10, $match); + $names = array_column($result['items'], 'name'); + $this->assertContains($match, $names); + }, 60000, 2000); + + $this->assertNotContains($other, $names); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $match); + $this->vcsAdapter->deleteRepository(static::$owner, $other); + } + } + public function testSearchRepositories(): void { $repo1Name = 'test-search-repo1-' . \uniqid(); @@ -871,11 +981,7 @@ public function testSearchRepositories(): void $this->assertArrayHasKey('id', $repository); $this->assertArrayHasKey('name', $repository); $this->assertArrayHasKey('private', $repository); - $this->assertArrayHasKey('pushed_at', $repository); - $this->assertTrue( - $repository['pushed_at'] === null || \strtotime((string) $repository['pushed_at']) !== false, - 'pushed_at is neither null nor a parseable timestamp' - ); + $this->assertPushedAtOnEmptyRepository($repository); } } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repo1Name); @@ -902,7 +1008,7 @@ public function testGetPullRequest(): void 'Test PR description' ); - $prNumber = $pr['iid'] ?? $pr['number'] ?? 0; + $prNumber = $this->pullRequestNumberOf($pr); $this->assertGreaterThan(0, $prNumber); $result = $this->vcsAdapter->getPullRequest(static::$owner, $repositoryName, $prNumber); @@ -915,7 +1021,7 @@ public function testGetPullRequest(): void $this->assertArrayHasKey('base', $result); $this->assertSame($prNumber, $result['number']); $this->assertSame('Test PR', $result['title']); - $this->assertContains($result['state'], ['open', 'opened']); + $this->assertSame(static::$openPullRequestState, $result['state']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -939,7 +1045,7 @@ public function testGetPullRequestFiles(): void static::$defaultBranch ); - $prNumber = $pr['iid'] ?? $pr['number'] ?? 0; + $prNumber = $this->pullRequestNumberOf($pr); $result = []; $this->assertEventually(function () use (&$result, $repositoryName, $prNumber) { @@ -991,7 +1097,7 @@ public function testGetPullRequestFromBranch(): void $this->assertIsArray($result); $this->assertNotEmpty($result); $this->assertArrayHasKey('head', $result); - $this->assertSame('my-feature', $result['head']['ref'] ?? ''); + $this->assertSame('my-feature', $result['head']['ref']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -1034,7 +1140,7 @@ public function testCreateComment(): void static::$defaultBranch ); - $prNumber = $pr['iid'] ?? $pr['number'] ?? 0; + $prNumber = $this->pullRequestNumberOf($pr); $this->assertGreaterThan(0, $prNumber); $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'Test comment'); @@ -1064,7 +1170,7 @@ public function testGetComment(): void static::$defaultBranch ); - $prNumber = $pr['iid'] ?? $pr['number'] ?? 0; + $prNumber = $this->pullRequestNumberOf($pr); $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'Test comment'); $result = $this->vcsAdapter->getComment(static::$owner, $repositoryName, $commentId); @@ -1094,7 +1200,7 @@ public function testUpdateComment(): void static::$defaultBranch ); - $prNumber = $pr['iid'] ?? $pr['number'] ?? 0; + $prNumber = $this->pullRequestNumberOf($pr); $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'Original comment'); $updatedCommentId = $this->vcsAdapter->updateComment(static::$owner, $repositoryName, $commentId, 'Updated comment'); @@ -1145,7 +1251,8 @@ public function testGetUser(): void $this->assertArrayHasKey('id', $result); $this->assertNotEmpty($result['id']); // GitLab reports the handle as 'username', Gitea and its forks as 'login' - $this->assertSame(static::$existingUser, $result['username'] ?? $result['login'] ?? ''); + $this->assertArrayHasKey(static::$userHandleField, $result); + $this->assertSame(static::$existingUser, $result[static::$userHandleField]); } public function testGetUserWithInvalidUsername(): void @@ -1168,6 +1275,12 @@ public function testGetCommitWithInvalidHash(): void } } + public function testGetEventInvalidPayload(): void + { + $this->expectException(Exception::class); + $this->vcsAdapter->getEvent('push', 'invalid json'); + } + public function testGetEventUnsupportedEvent(): void { $payload = json_encode(['test' => 'data']); From a68b687f9002961efd25d8a33ae0be92526219a9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 12:35:05 +0530 Subject: [PATCH 03/20] Assert pushed_at, clone failure and webhook scopes as they actually behave Three assertions were written from assumption rather than observation, and CI corrected two of them: - pushed_at was tolerated as null or a timestamp, and I had pinned the null case to GitHub. GitHub actually reports a timestamp for a repository with no commits too, so the null branch was never reachable for any provider - every adapter now has to report a parseable timestamp. - a failed clone was accepted if the command exited non-zero or checked out nothing. On Gitea and Gogs it exits zero, because the command sets up a local repository before discovering the remote is missing, so the checkout being empty is the only real signal and is now the assertion. - supported webhook scopes were only asserted to be non-empty; the exact set is now declared per provider, with the installation scope only on GitHub. Also stops the two search tests orphaning their first repository when the second one fails to create, and asserts a commit url points at the repository it came from. --- tests/VCS/Adapter/GitHubTest.php | 3 +- tests/VCS/Base.php | 76 ++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 46c42a56..be9c7ff7 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -14,7 +14,8 @@ class GitHubTest extends Base protected static string $owner = ''; protected static string $installationId = ''; protected static string $defaultBranch = 'main'; - protected static bool $reportsPushedAtOnEmptyRepository = false; + /** @var array */ + protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; protected static string $eventHeader = 'x-github-event'; protected static string $signatureHeader = 'x-hub-signature-256'; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 03a6e3c1..93656137 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -32,10 +32,12 @@ abstract class Base extends TestCase protected static string $openPullRequestState = 'open'; /** - * Whether the provider reports a pushed_at timestamp for a repository - * that has no commits yet. GitHub reports null until the first push. + * Scopes the provider accepts webhooks at. Only GitHub registers them + * once per installation as well as per repository. + * + * @var array */ - protected static bool $reportsPushedAtOnEmptyRepository = true; + protected static array $supportedWebhookScopes = [Git::WEBHOOK_SCOPE_REPOSITORY]; /** * Headers the provider sends its webhook event type and signature under. @@ -152,20 +154,14 @@ protected function pullRequestNumberOf(array $pullRequest): int } /** - * A repository with no commits reports pushed_at differently per provider. + * Every provider reports pushed_at as a timestamp, including for a + * repository that has no commits yet. * * @param array $repository */ - protected function assertPushedAtOnEmptyRepository(array $repository): void + protected function assertPushedAt(array $repository): void { $this->assertArrayHasKey('pushed_at', $repository); - - if (!static::$reportsPushedAtOnEmptyRepository) { - $this->assertNull($repository['pushed_at']); - - return; - } - $this->assertNotFalse( \strtotime((string) $repository['pushed_at']), 'pushed_at is not a parseable timestamp' @@ -201,6 +197,19 @@ protected function getLatestCommitEventually(string $repositoryName): array return $commit; } + /** + * Remove a repository during cleanup, tolerating one that was never created. + * Deleting is asserted on its own in the delete tests. + */ + protected function deleteRepositoryIfExists(string $repositoryName): void + { + try { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } catch (\Throwable) { + // nothing to clean up + } + } + protected function deleteLastWebhookRequest(): void { $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); @@ -220,9 +229,7 @@ public function testWebhookHeaderNames(): void public function testGetSupportedWebhookScopes(): void { - $scopes = $this->vcsAdapter->getSupportedWebhookScopes(); - $this->assertIsArray($scopes); - $this->assertNotEmpty($scopes); + $this->assertSame(static::$supportedWebhookScopes, $this->vcsAdapter->getSupportedWebhookScopes()); } public function testCreateRepository(): void @@ -235,7 +242,7 @@ public function testCreateRepository(): void $this->assertIsArray($result); $this->assertArrayHasKey('name', $result); $this->assertSame($repositoryName, $result['name']); - $this->assertPushedAtOnEmptyRepository($result); + $this->assertPushedAt($result); $this->assertFalse($this->isPrivate($result), 'createRepository() reported the new repository as private'); $this->assertSame($this->ownerPath(), $this->ownerOf($result)); @@ -277,7 +284,7 @@ public function testGetRepository(): void $this->assertIsArray($result); $this->assertSame($repositoryName, $result['name']); - $this->assertPushedAtOnEmptyRepository($result); + $this->assertPushedAt($result); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } @@ -660,7 +667,7 @@ public function testGetCommit(): void $this->assertArrayHasKey('commitAuthorUrl', $result); $this->assertSame($commitHash, $result['commitHash']); $this->assertStringStartsWith($customMessage, $result['commitMessage']); - $this->assertNotEmpty($result['commitUrl']); + $this->assertStringContainsString($repositoryName, $result['commitUrl']); $this->assertNotEmpty($result['commitAuthor']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); @@ -684,7 +691,7 @@ public function testGetLatestCommit(): void $this->assertIsArray($commit1); $this->assertNotEmpty($commit1['commitHash']); $this->assertStringStartsWith($firstMessage, $commit1['commitMessage']); - $this->assertNotEmpty($commit1['commitUrl']); + $this->assertStringContainsString($repositoryName, $commit1['commitUrl']); $this->assertNotEmpty($commit1['commitAuthor']); $commit1Hash = $commit1['commitHash']; @@ -747,7 +754,8 @@ public function testUpdateCommitStatus(): void $written = null; foreach ($statuses as $status) { - if (($status['context'] ?? '') === 'ci/build') { + $this->assertArrayHasKey('context', $status); + if ($status['context'] === 'ci/build') { $written = $status; break; } @@ -853,8 +861,10 @@ public function testGenerateCloneCommandWithInvalidRepository(): void $output = []; \exec($command . ' 2>&1', $output, $exitCode); - $cloneFailed = ($exitCode !== 0) || !file_exists($directory . '/README.md'); - $this->assertTrue($cloneFailed, 'Clone should have failed for nonexistent repository'); + // The command sets up a local repository first, so a missing remote + // does not have to fail it outright - what matters is that nothing + // from the repository was checked out. + $this->assertFileDoesNotExist($directory . '/README.md'); } finally { if (\is_dir($directory)) { \exec('rm -rf ' . escapeshellarg($directory)); @@ -938,10 +948,10 @@ public function testSearchRepositoriesMatchesName(): void $match = 'test-search-match-' . \uniqid(); $other = 'test-search-other-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $match, false); - $this->vcsAdapter->createRepository(static::$owner, $other, false); - try { + $this->vcsAdapter->createRepository(static::$owner, $match, false); + $this->vcsAdapter->createRepository(static::$owner, $other, false); + $names = []; $this->assertEventually(function () use (&$names, $match) { $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10, $match); @@ -951,8 +961,8 @@ public function testSearchRepositoriesMatchesName(): void $this->assertNotContains($other, $names); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $match); - $this->vcsAdapter->deleteRepository(static::$owner, $other); + $this->deleteRepositoryIfExists($match); + $this->deleteRepositoryIfExists($other); } } @@ -961,10 +971,10 @@ public function testSearchRepositories(): void $repo1Name = 'test-search-repo1-' . \uniqid(); $repo2Name = 'test-search-repo2-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repo1Name, false); - $this->vcsAdapter->createRepository(static::$owner, $repo2Name, false); - try { + $this->vcsAdapter->createRepository(static::$owner, $repo1Name, false); + $this->vcsAdapter->createRepository(static::$owner, $repo2Name, false); + $result = []; $this->assertEventually(function () use (&$result) { $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10); @@ -981,11 +991,11 @@ public function testSearchRepositories(): void $this->assertArrayHasKey('id', $repository); $this->assertArrayHasKey('name', $repository); $this->assertArrayHasKey('private', $repository); - $this->assertPushedAtOnEmptyRepository($repository); + $this->assertPushedAt($repository); } } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repo1Name); - $this->vcsAdapter->deleteRepository(static::$owner, $repo2Name); + $this->deleteRepositoryIfExists($repo1Name); + $this->deleteRepositoryIfExists($repo2Name); } } From 7d672cd351eed9879289ffa4630f2cb16ce3e465 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 12:39:01 +0530 Subject: [PATCH 04/20] Only tolerate a missing repository when cleaning up The cleanup helper caught every throwable, so an auth, transport or provider failure during teardown would pass silently and leave the repository behind for later runs to trip over. Adapters carry the HTTP status as the exception code, so a repository that was never created is a 404 and stays tolerated; everything else surfaces. --- tests/VCS/Base.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 93656137..f27c1535 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -198,15 +198,23 @@ protected function getLatestCommitEventually(string $repositoryName): array } /** - * Remove a repository during cleanup, tolerating one that was never created. - * Deleting is asserted on its own in the delete tests. + * Remove a repository during cleanup. One that was never created is not a + * failure, but anything else - auth, transport, a provider fault - has to + * surface rather than quietly leave the repository behind. */ protected function deleteRepositoryIfExists(string $repositoryName): void { try { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } catch (\Throwable) { - // nothing to clean up + } catch (RepositoryNotFound) { + return; + } catch (Exception $e) { + // Adapters carry the HTTP status as the exception code + if ($e->getCode() === 404) { + return; + } + + throw $e; } } From f202ea655d29cedf07b094db9022a80e53c20234 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 12:43:24 +0530 Subject: [PATCH 05/20] Attempt every repository before reporting a cleanup failure Rethrowing on the first deletion left the second repository untouched, so surfacing one failure created another leak. Cleanup now takes every repository, skips the ones that were never created, and reports whatever actually failed once all of them have been attempted. --- tests/VCS/Base.php | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index f27c1535..5fa23a2f 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -198,23 +198,32 @@ protected function getLatestCommitEventually(string $repositoryName): array } /** - * Remove a repository during cleanup. One that was never created is not a + * Remove repositories during cleanup. One that was never created is not a * failure, but anything else - auth, transport, a provider fault - has to - * surface rather than quietly leave the repository behind. + * surface rather than quietly leave the repository behind. Every repository + * is attempted before reporting, so one failure cannot strand the rest. */ - protected function deleteRepositoryIfExists(string $repositoryName): void + protected function deleteRepositoriesIfExist(string ...$repositoryNames): void { - try { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } catch (RepositoryNotFound) { - return; - } catch (Exception $e) { - // Adapters carry the HTTP status as the exception code - if ($e->getCode() === 404) { - return; + $failures = []; + + foreach ($repositoryNames as $repositoryName) { + try { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } catch (RepositoryNotFound) { + continue; + } catch (Exception $e) { + // Adapters carry the HTTP status as the exception code + if ($e->getCode() === 404) { + continue; + } + + $failures[] = "{$repositoryName}: {$e->getMessage()}"; } + } - throw $e; + if ($failures !== []) { + throw new Exception('Failed to clean up ' . \implode(', ', $failures)); } } @@ -969,8 +978,7 @@ public function testSearchRepositoriesMatchesName(): void $this->assertNotContains($other, $names); } finally { - $this->deleteRepositoryIfExists($match); - $this->deleteRepositoryIfExists($other); + $this->deleteRepositoriesIfExist($match, $other); } } @@ -1002,8 +1010,7 @@ public function testSearchRepositories(): void $this->assertPushedAt($repository); } } finally { - $this->deleteRepositoryIfExists($repo1Name); - $this->deleteRepositoryIfExists($repo2Name); + $this->deleteRepositoriesIfExist($repo1Name, $repo2Name); } } From fa050f26a257a4e10e40ca75a9b7dc4abc368831 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 12:53:15 +0530 Subject: [PATCH 06/20] Wait for the commit before writing a status or a tag to it GitLab answered a commit status POST with 403 in CI: the test read the latest commit directly and wrote to it straight away, while the shared commit status test that waits through getLatestCommitEventually passed in the same run. The two GitLab commit status tests and Gitea's tag test now use that helper too, which is what every other test in the suite does. --- tests/VCS/Adapter/GitLabTest.php | 6 ++---- tests/VCS/Adapter/GiteaTest.php | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index ee3913d6..59b45746 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -133,8 +133,7 @@ public function testGetCommitStatuses(): void try { $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['commitHash']; + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; $this->vcsAdapter->updateCommitStatus( $repositoryName, @@ -169,8 +168,7 @@ public function testGetCommitStatusesEmptyForNewCommit(): void try { $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['commitHash']; + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; $result = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 280e7f2e..6a03212b 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -409,8 +409,7 @@ public function testCreateTag(): void try { $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['commitHash']; + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; $result = $this->vcsAdapter->createTag( static::$owner, From 90fef7ed6860a88f7b1823f633e8525be9c5b3ad Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 13:01:23 +0530 Subject: [PATCH 07/20] Keep cleanup from replacing the reason a test failed Reporting a cleanup failure from finally overwrote whatever the test body had already raised. Cleanup is now two things: deleteRepositories, asserted on the path where the test passed, and discardRepositories, silent on the path where a failure is already on its way out. --- tests/VCS/Base.php | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 5fa23a2f..080fe982 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -198,12 +198,12 @@ protected function getLatestCommitEventually(string $repositoryName): array } /** - * Remove repositories during cleanup. One that was never created is not a - * failure, but anything else - auth, transport, a provider fault - has to - * surface rather than quietly leave the repository behind. Every repository - * is attempted before reporting, so one failure cannot strand the rest. + * Remove repositories a passing test created. Every repository is attempted + * before reporting, so one failure cannot strand the rest, and a repository + * that was never created is not a failure. Anything else - auth, transport, + * a provider fault - surfaces rather than leaving the repository behind. */ - protected function deleteRepositoriesIfExist(string ...$repositoryNames): void + protected function deleteRepositories(string ...$repositoryNames): void { $failures = []; @@ -227,6 +227,22 @@ protected function deleteRepositoriesIfExist(string ...$repositoryNames): void } } + /** + * Remove repositories while a failure is already on its way out. Reporting + * a cleanup problem here would replace the reason the test actually failed, + * so this stays quiet. + */ + protected function discardRepositories(string ...$repositoryNames): void + { + foreach ($repositoryNames as $repositoryName) { + try { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } catch (\Throwable) { + continue; + } + } + } + protected function deleteLastWebhookRequest(): void { $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); @@ -977,9 +993,13 @@ public function testSearchRepositoriesMatchesName(): void }, 60000, 2000); $this->assertNotContains($other, $names); - } finally { - $this->deleteRepositoriesIfExist($match, $other); + } catch (\Throwable $e) { + $this->discardRepositories($match, $other); + + throw $e; } + + $this->deleteRepositories($match, $other); } public function testSearchRepositories(): void @@ -1009,9 +1029,13 @@ public function testSearchRepositories(): void $this->assertArrayHasKey('private', $repository); $this->assertPushedAt($repository); } - } finally { - $this->deleteRepositoriesIfExist($repo1Name, $repo2Name); + } catch (\Throwable $e) { + $this->discardRepositories($repo1Name, $repo2Name); + + throw $e; } + + $this->deleteRepositories($repo1Name, $repo2Name); } public function testGetPullRequest(): void From f46c5ee305f275bf6716042b996f43083f3dc632 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 13:46:34 +0530 Subject: [PATCH 08/20] Give Git a default presigned url that reports it unsupported Three adapters implement getRepositoryPresignedUrl() but nothing declared it, so the shared tests could not call it. A default on Git makes the contract reachable while leaving adapters that do not offer archives - Gogs, and anything being written against this class - working as they are. --- src/VCS/Adapter/Git.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 764affea..d843c381 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -2,6 +2,7 @@ namespace Utopia\VCS\Adapter; +use Exception; use Utopia\VCS\Adapter; use Utopia\Cache\Cache; @@ -96,6 +97,22 @@ abstract public function createWebhook(string $owner, string $repositoryName, st */ abstract public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array; + /** + * Get a short-lived URL to download the repository archive. + * + * Not every provider offers one, so the default reports it as unsupported + * rather than forcing an implementation. + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $ref Branch, tag or commit to archive + * @param string $format Either 'tarball' or 'zipball' + */ + public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string + { + throw new Exception('getRepositoryPresignedUrl() is not supported by ' . $this->getName()); + } + /** * Get commit statuses * From e70a83fa34a56ee7f4252cf6855ee3b08ab2398e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 13:46:34 +0530 Subject: [PATCH 09/20] Share the rest of the adapter tests through Base Following review: the adapter classes were still carrying tests that only looked provider specific. Moved to Base, with the parts that genuinely differ declared rather than duplicated: - both webhook round trips, through an awaitWebhook() helper. This also puts them near the end of a run again, which they lost when earlier tests moved to Base and inherited tests started running after a subclass's own - validateWebhookEvent, via a signWebhookPayload() each adapter implements: GitHub prefixes its HMAC, Gitea sends a plain one, GitLab sends the secret verbatim - presigned urls, commit statuses, createTag, tree with a slash in the branch name, search pagination, listTags on a commit-less repository, hasAccessToAllRepositories, getInstallationRepository, getOwnerName with an id that does not exist Two provider facts came out of running it rather than reading the code: Gitea reports a newly opened pull request as 'synchronized' and GitLab as 'synchronize', because both follow the opened event with a sync for the head they just pushed and the catcher keeps only the last delivery. No test is defined in two adapter classes any more. GiteaTest 634 -> 300 lines, GitLabTest 682 -> 461. What stays adapter specific is genuinely provider bound: hand-built getEvent payloads, GitHub's check runs, pagination, blob SHA and case sensitivity, GitLab's namespaces and path sentinels, Gitea's avatar host and delete-then-read. --- tests/VCS/Adapter/GitHubTest.php | 95 +++----- tests/VCS/Adapter/GitLabTest.php | 223 ++----------------- tests/VCS/Adapter/GiteaTest.php | 270 +---------------------- tests/VCS/Adapter/GogsTest.php | 10 + tests/VCS/Base.php | 360 ++++++++++++++++++++++++++++++- 5 files changed, 426 insertions(+), 532 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index be9c7ff7..183e3cc6 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -16,6 +16,15 @@ class GitHubTest extends Base protected static string $defaultBranch = 'main'; /** @var array */ protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; + + protected static bool $supportsInstallationRepository = true; + protected static string $presignedTarballFragment = 'tarball'; + protected static string $presignedZipballFragment = 'zipball'; + + protected function signWebhookPayload(string $payload, string $secret): string + { + return 'sha256=' . hash_hmac('sha256', $payload, $secret); + } protected static string $eventHeader = 'x-github-event'; protected static string $signatureHeader = 'x-hub-signature-256'; @@ -160,15 +169,6 @@ public function testGetEventInstallation(): void $this->assertSame('1234', $result['installationId']); } - public function testValidateWebhookEvent(): void - { - $payload = '{"action":"push"}'; - $secret = 'my-webhook-secret'; - $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); - - $this->assertTrue($this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret)); - $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, 'sha256=wrongsig', $secret)); - } public function testGetRepositoryContentSha(): void { @@ -597,25 +597,7 @@ public function testGetOwnerName(): void $this->assertSame(static::$owner, $result); } - public function testHasAccessToAllRepositories(): void - { - $result = $this->vcsAdapter->hasAccessToAllRepositories(); - $this->assertIsBool($result); - } - - public function testGetInstallationRepository(): void - { - $repositoryName = 'test-installation-repo-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $repo = $this->vcsAdapter->getInstallationRepository($repositoryName); - $this->assertIsArray($repo); - $this->assertSame($repositoryName, $repo['name']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } public function testGetPullRequest(): void { @@ -672,6 +654,21 @@ public function testGenerateCloneCommandWithTag(): void $this->markTestSkipped('createTag() is not implemented for GitHub'); } + public function testCreateTag(): void + { + $this->markTestSkipped('createTag() is not implemented for GitHub'); + } + + public function testGetCommitStatuses(): void + { + $this->markTestSkipped('getCommitStatuses() is not implemented for GitHub'); + } + + public function testGetCommitStatusesEmptyForNewCommit(): void + { + $this->markTestSkipped('getCommitStatuses() is not implemented for GitHub'); + } + public function testCreateRepositoryWithInvalidName(): void { $this->markTestSkipped('GitHub normalizes spaces in repository names instead of rejecting them'); @@ -692,6 +689,16 @@ public function testGetOwnerNameWithNullRepositoryId(): void $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); } + public function testWebhookPushEvent(): void + { + $this->markTestSkipped('github.com cannot deliver webhooks to the local request catcher'); + } + + public function testWebhookPullRequestEvent(): void + { + $this->markTestSkipped('github.com cannot deliver webhooks to the local request catcher'); + } + public function testListRepositoryLanguages(): void { $repositoryName = 'test-list-repository-languages-' . \uniqid(); @@ -722,40 +729,6 @@ public function testListRepositoryLanguages(): void } } - public function testGetRepositoryPresignedUrl(): void - { - $repositoryName = 'test-presigned-url-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - /** @var GitHub $adapter */ - $adapter = $this->vcsAdapter; - - $tarballUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch); - $this->assertNotEmpty($tarballUrl); - $this->assertStringStartsWith('https://', $tarballUrl); - - $zipballUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch, 'zipball'); - $this->assertNotEmpty($zipballUrl); - $this->assertStringStartsWith('https://', $zipballUrl); - - // Defaults to the default branch when no ref is given - $defaultUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName); - $this->assertNotEmpty($defaultUrl); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testGetRepositoryPresignedUrlWithInvalidFormat(): void - { - /** @var GitHub $adapter */ - $adapter = $this->vcsAdapter; - $this->expectException(\Exception::class); - $adapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); - } } diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 59b45746..826b175b 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -16,6 +16,20 @@ class GitLabTest extends Base protected static string $openPullRequestState = 'opened'; protected static string $eventHeader = 'x-gitlab-event'; protected static string $signatureHeader = 'x-gitlab-token'; + protected static string $pushEventName = 'Push Hook'; + protected static string $pullRequestEventName = 'Merge Request Hook'; + + /** @var array */ + protected static array $pullRequestOpenedActions = ['opened', 'synchronize']; + + protected static string $presignedTarballFragment = '/repository/archive.tar.gz?access_token='; + protected static string $presignedZipballFragment = '/repository/archive.zip?access_token='; + protected static string $repositoryNotFoundException = \Exception::class; + + protected function signWebhookPayload(string $payload, string $secret): string + { + return $secret; + } protected function setupAdapter(): void { @@ -99,18 +113,6 @@ protected function pullRequestNumberOf(array $pullRequest): int } - public function testListTagsCommitlessRepository(): void - { - $repositoryName = 'test-list-tags-commitless-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - // No commits at all, which GitLab answers differently from an empty tag list - $this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName)); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } protected function setupGitLab(): void { @@ -126,168 +128,11 @@ protected function setupGitLab(): void - public function testGetCommitStatuses(): void - { - $repositoryName = 'test-get-commit-statuses-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $this->vcsAdapter->updateCommitStatus( - $repositoryName, - $commitHash, - static::$owner, - 'pending', - 'Build started', - '', - 'ci/test' - ); - - $result = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); - - $this->assertIsArray($result); - $this->assertNotEmpty($result); - - foreach ($result as $status) { - $this->assertArrayHasKey('state', $status); - $this->assertArrayHasKey('description', $status); - $this->assertArrayHasKey('target_url', $status); - $this->assertArrayHasKey('context', $status); - } - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testGetCommitStatusesEmptyForNewCommit(): void - { - $repositoryName = 'test-get-commit-statuses-empty-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $result = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - public function testValidateWebhookEvent(): void - { - $secret = 'my-secret-token'; - $payload = '{"object_kind":"push"}'; - - // GitLab sends the secret verbatim rather than an HMAC of the payload - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $secret, $secret) - ); - $hmacSignature = hash_hmac('sha256', $payload, $secret); - $this->assertFalse( - $this->vcsAdapter->validateWebhookEvent($payload, $hmacSignature, $secret) - ); - $this->assertFalse( - $this->vcsAdapter->validateWebhookEvent($payload, 'wrong-token', $secret) - ); - } - - public function testWebhookPushEvent(): void - { - $repositoryName = 'test-webhook-push-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - // Clear previous requests - $this->deleteLastWebhookRequest(); - - // Create webhook - $webhookId = $this->vcsAdapter->createWebhook( - static::$owner, - $repositoryName, - System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'), - 'test-secret', - ['push'] - ); - $this->assertGreaterThan(0, $webhookId); - - // Trigger push by creating a file - $this->vcsAdapter->createFile( - static::$owner, - $repositoryName, - 'README.md', - '# Test', - 'Initial commit' - ); - - // GitLab queues hook deliveries through Sidekiq, which can still be - // warming up right after the instance becomes reachable, so allow more - // than the default wait - $payload = []; - $this->assertEventually(function () use (&$payload) { - $data = $this->getLastWebhookRequest(); - $this->assertNotEmpty($data); - $payload = \json_decode($data['data'] ?? '{}', true); - $this->assertNotEmpty($payload); - }, 60000, 2000); - - $this->assertSame('push', $payload['object_kind'] ?? ''); - $this->assertNotEmpty($payload['checkout_sha'] ?? ''); - - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testWebhookPullRequestEvent(): void - { - $repositoryName = 'test-webhook-mr-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - // Clear previous requests - $this->deleteLastWebhookRequest(); - - // Create webhook - $webhookId = $this->vcsAdapter->createWebhook( - static::$owner, - $repositoryName, - System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'), - 'test-secret', - ['pull_request'] - ); - $this->assertGreaterThan(0, $webhookId); - - // Setup and create MR - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature', static::$defaultBranch); - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'feature.txt', 'feature', 'Add feature', 'feature'); - $this->vcsAdapter->createPullRequest(static::$owner, $repositoryName, 'Test MR', 'feature', static::$defaultBranch); - - // Wait for webhook delivery; same Sidekiq warm-up allowance as the push test - $payload = []; - $this->assertEventually(function () use (&$payload) { - $data = $this->getLastWebhookRequest(); - $this->assertNotEmpty($data); - $payload = \json_decode($data['data'] ?? '{}', true); - $this->assertNotEmpty($payload); - }, 60000, 2000); - - $this->assertSame('merge_request', $payload['object_kind'] ?? ''); - $this->assertContains($payload['object_attributes']['action'] ?? '', ['open', 'update']); - - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } public function testGetEventPush(): void { @@ -384,26 +229,6 @@ public function testGetEventPullRequest(): void } - public function testCreateWebhook(): void - { - $repositoryName = 'test-create-webhook-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $webhookId = $this->vcsAdapter->createWebhook( - static::$owner, - $repositoryName, - 'http://example.com/webhook', - 'secret-token', - ['push', 'pull_request'] - ); - - $this->assertIsInt($webhookId); - $this->assertGreaterThan(0, $webhookId); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } public function testGetEventPushMatchesCheckoutSha(): void { @@ -532,26 +357,6 @@ public function testGetEventPullRequestDetectsExternal(): void $this->assertTrue($result['external']); } - public function testGetRepositoryPresignedUrl(): void - { - /** @var GitLab $adapter */ - $adapter = $this->vcsAdapter; - $owner = static::$owner; - - $url = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch); - $this->assertStringContainsString('/repository/archive.tar.gz?access_token=', $url); - $this->assertStringContainsString('&sha=' . static::$defaultBranch, $url); - - $zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball'); - $this->assertStringContainsString('/repository/archive.zip?access_token=', $zip); - - // Without a ref the sha param is omitted so the server uses the default branch - $noRef = $adapter->getRepositoryPresignedUrl($owner, 'some-repo'); - $this->assertStringNotContainsString('sha=', $noRef); - - $this->expectException(\Exception::class); - $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'invalid'); - } public function testListRepositoryContentsRootSentinels(): void { diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 6a03212b..4f9bdbd7 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -18,6 +18,17 @@ class GiteaTest extends Base protected static string $userHandleField = 'login'; protected static string $eventHeader = 'x-gitea-event'; protected static string $signatureHeader = 'x-gitea-signature'; + + /** @var array */ + protected static array $pullRequestOpenedActions = ['opened', 'synchronized']; + + protected static string $presignedTarballFragment = '.tar.gz?token='; + protected static string $presignedZipballFragment = '.zip?token='; + + protected function signWebhookPayload(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } protected static string $avatarDomain = 'gravatar.com'; protected function setupAdapter(): void @@ -57,31 +68,6 @@ protected function setupGitea(): void } } - public function testGetRepositoryPresignedUrl(): void - { - /** @var Gitea $adapter */ - $adapter = $this->vcsAdapter; - $owner = static::$owner; - - $url = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch); - $this->assertStringContainsString("/repos/{$owner}/some-repo/archive/" . static::$defaultBranch . '.tar.gz?token=', $url); - - $zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball'); - $this->assertStringContainsString('.zip?token=', $zip); - - // No ref: the default branch is resolved from the repository - $repositoryName = 'test-presigned-url-' . \uniqid(); - $adapter->createRepository($owner, $repositoryName, false); - try { - $noRef = $adapter->getRepositoryPresignedUrl($owner, $repositoryName); - $this->assertStringContainsString('/archive/' . static::$defaultBranch . '.tar.gz?token=', $noRef); - } finally { - $adapter->deleteRepository($owner, $repositoryName); - } - - $this->expectException(\Exception::class); - $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'invalid'); - } public function testGetRepositoryAfterDeleteFails(): void { @@ -113,42 +99,8 @@ public function testGetCommitAuthorAvatar(): void } } - public function testHasAccessToAllRepositories(): void - { - $this->assertTrue($this->vcsAdapter->hasAccessToAllRepositories()); - } - public function testGetRepositoryTreeWithSlashInBranchName(): void - { - $repositoryName = 'test-branch-with-slash-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature/test-branch', static::$defaultBranch); - - $tree = $this->vcsAdapter->getRepositoryTree(static::$owner, $repositoryName, 'feature/test-branch'); - $this->assertIsArray($tree); - $this->assertNotEmpty($tree); - $this->assertContains('README.md', $tree); - - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - - public function testGetRepositoryName(): void - { - $repositoryName = 'test-get-repository-name-' . \uniqid(); - $created = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - $this->assertIsArray($created); - $this->assertArrayHasKey('id', $created); - $this->assertIsScalar($created['id']); - $repositoryId = (string) $created['id']; - $result = $this->vcsAdapter->getRepositoryName($repositoryId); - - $this->assertSame($repositoryName, $result); - $this->assertTrue($this->vcsAdapter->deleteRepository(static::$owner, $repositoryName)); - } public function testGetEventPush(): void @@ -330,219 +282,19 @@ public function testGetEventPullRequestExternal(): void $this->assertTrue($result['external']); } - public function testValidateWebhookEvent(): void - { - $payload = 'test payload content'; - $secret = 'my-webhook-secret'; - $validSignature = hash_hmac('sha256', $payload, $secret); - $result = $this->vcsAdapter->validateWebhookEvent($payload, $validSignature, $secret); - $this->assertTrue($result); - } - public function testValidateWebhookEventInvalid(): void - { - $payload = 'test payload content'; - $secret = 'my-webhook-secret'; - $invalidSignature = 'wrong-signature'; - $result = $this->vcsAdapter->validateWebhookEvent($payload, $invalidSignature, $secret); - $this->assertFalse($result); - } - public function testSearchRepositoriesPagination(): void - { - $repo1 = 'test-pagination-1-' . \uniqid(); - $repo2 = 'test-pagination-2-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repo1, false); - $this->vcsAdapter->createRepository(static::$owner, $repo2, false); - try { - $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 1, 'test-pagination'); - - $this->assertSame(1, count($result['items'])); - $this->assertGreaterThanOrEqual(2, $result['total']); - - $result2 = $this->vcsAdapter->searchRepositories(static::$owner, 2, 1, 'test-pagination'); - $this->assertSame(1, count($result2['items'])); - - $result20 = $this->vcsAdapter->searchRepositories(static::$owner, 20, 1, 'test-pagination'); - $this->assertIsArray($result20); - $this->assertEmpty($result20['items']); - - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repo1); - $this->vcsAdapter->deleteRepository(static::$owner, $repo2); - } - } - public function testGetOwnerNameWithInvalidRepositoryId(): void - { - $this->expectException(RepositoryNotFound::class); - - $this->vcsAdapter->getOwnerName('', 999999999); - } - - - public function testGetInstallationRepository(): void - { - // This method is not applicable for this adapter - $this->expectException(\Exception::class); - $this->expectExceptionMessage('not applicable for this adapter'); - - $this->vcsAdapter->getInstallationRepository('any-repo-name'); - } - - public function testCreateTag(): void - { - $repositoryName = 'test-create-tag-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $result = $this->vcsAdapter->createTag( - static::$owner, - $repositoryName, - 'v1.0.0', - $commitHash, - 'First release' - ); - - $this->assertIsArray($result); - $this->assertNotEmpty($result); - $this->assertArrayHasKey('name', $result); - $this->assertSame('v1.0.0', $result['name']); - $this->assertArrayHasKey('commit', $result); - $this->assertArrayHasKey('sha', $result['commit']); - $this->assertSame($commitHash, $result['commit']['sha']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testWebhookPushEvent(): void - { - $repositoryName = 'test-webhook-push-' . \uniqid(); - $secret = 'test-webhook-secret-' . \uniqid(); - - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); - $this->deleteLastWebhookRequest(); - $this->vcsAdapter->createWebhook(static::$owner, $repositoryName, $catcherUrl . '/webhook', $secret); - - // Trigger a real push by creating a file - $this->vcsAdapter->createFile( - static::$owner, - $repositoryName, - 'README.md', - '# Webhook Test', - 'Initial commit' - ); - - // Wait for push webhook to arrive automatically - $eventHeader = $this->vcsAdapter->getEventHeaderName(); - $webhookData = []; - $this->assertEventually(function () use (&$webhookData, $eventHeader) { - $webhookData = $this->getLastWebhookRequest(); - $this->assertNotEmpty($webhookData, 'No webhook received'); - $this->assertNotEmpty($webhookData['data'] ?? '', 'Webhook payload is empty'); - $this->assertSame('push', $this->findHeader($webhookData['headers'] ?? [], $eventHeader), 'Expected push event'); - }, 15000, 500); - - $payload = $webhookData['data']; - $signatureHeader = $this->vcsAdapter->getSignatureHeaderName(); - $signature = $this->findHeader($webhookData['headers'] ?? [], $signatureHeader); - - $this->assertNotEmpty($signature, 'Missing ' . $signatureHeader . ' header'); - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), - 'Webhook signature validation failed' - ); - - $event = $this->vcsAdapter->getEvent('push', $payload); - $this->assertIsArray($event); - $this->assertSame(static::$defaultBranch, $event['branch']); - $this->assertSame($repositoryName, $event['repositoryName']); - $this->assertSame(static::$owner, $event['owner']); - $this->assertNotEmpty($event['commitHash']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testWebhookPullRequestEvent(): void - { - $repositoryName = 'test-webhook-pr-' . \uniqid(); - $secret = 'test-webhook-secret-' . \uniqid(); - - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - // Create all files BEFORE configuring webhook - // so those push events don't pollute the catcher - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-branch', static::$defaultBranch); - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'feature.txt', 'content', 'Add feature', 'feature-branch'); - - $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); - $this->vcsAdapter->createWebhook(static::$owner, $repositoryName, $catcherUrl . '/webhook', $secret, ['pull_request']); - - // Clear after setup so only PR event will arrive - $this->deleteLastWebhookRequest(); - - // Trigger real PR event - $this->vcsAdapter->createPullRequest( - static::$owner, - $repositoryName, - 'Test Webhook PR', - 'feature-branch', - static::$defaultBranch - ); - - // Wait for pull_request webhook to arrive automatically - $eventHeader = $this->vcsAdapter->getEventHeaderName(); - $webhookData = []; - $this->assertEventually(function () use (&$webhookData, $eventHeader) { - $webhookData = $this->getLastWebhookRequest(); - $this->assertNotEmpty($webhookData, 'No webhook received'); - $this->assertNotEmpty($webhookData['data'] ?? '', 'Webhook payload is empty'); - $this->assertSame('pull_request', $this->findHeader($webhookData['headers'] ?? [], $eventHeader), 'Expected pull_request event'); - }, 15000, 500); - - $payload = $webhookData['data']; - $signatureHeader = $this->vcsAdapter->getSignatureHeaderName(); - $signature = $this->findHeader($webhookData['headers'] ?? [], $signatureHeader); - - $this->assertNotEmpty($signature, 'Missing ' . $signatureHeader . ' header'); - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), - 'Webhook signature validation failed' - ); - - $event = $this->vcsAdapter->getEvent('pull_request', $payload); - $this->assertIsArray($event); - $this->assertSame('feature-branch', $event['branch']); - $this->assertSame($repositoryName, $event['repositoryName']); - $this->assertSame(static::$owner, $event['owner']); - $this->assertContains($event['action'], ['opened', 'synchronized']); - $this->assertGreaterThan(0, $event['pullRequestNumber']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - } diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index cdb29a1a..cc5a3d39 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -105,6 +105,16 @@ public function testUpdateCommitStatusWithNonExistingRepository(): void $this->markTestSkipped('Gogs does not support commit status API'); } + public function testGetCommitStatuses(): void + { + $this->markTestSkipped('Gogs does not support commit status API'); + } + + public function testGetCommitStatusesEmptyForNewCommit(): void + { + $this->markTestSkipped('Gogs does not support commit status API'); + } + // Repository languages public function testListRepositoryLanguages(): void { diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 080fe982..4ef3df1a 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -39,6 +39,44 @@ abstract class Base extends TestCase */ protected static array $supportedWebhookScopes = [Git::WEBHOOK_SCOPE_REPOSITORY]; + /** + * Names the provider uses for the events it delivers. + */ + protected static string $pushEventName = 'push'; + + protected static string $pullRequestEventName = 'pull_request'; + + /** + * Actions the provider may report for a newly opened pull request. Gitea + * follows the opened event with a synchronized one for the head it just + * pushed, and the catcher only keeps the last delivery. + * + * @var array + */ + protected static array $pullRequestOpenedActions = ['opened']; + + /** + * Fragments the provider's archive URLs are built from. + */ + protected static string $presignedTarballFragment = '.tar.gz'; + + protected static string $presignedZipballFragment = '.zip'; + + /** + * Whether the provider's credentials reach every repository, and whether it + * can look one up through an installation at all. + */ + protected static bool $hasAccessToAllRepositories = true; + + protected static bool $supportsInstallationRepository = false; + + /** + * Exception the provider raises for a repository id that does not exist. + * + * @var class-string<\Throwable> + */ + protected static string $repositoryNotFoundException = RepositoryNotFound::class; + /** * Headers the provider sends its webhook event type and signature under. */ @@ -52,14 +90,17 @@ abstract class Base extends TestCase abstract protected function setupAdapter(): void; /** - * Webhook payloads and signature schemes are provider specific. + * Sign a payload the way the provider signs its webhooks. + */ + abstract protected function signWebhookPayload(string $payload, string $secret): string; + + /** + * Webhook payloads are provider specific. */ abstract public function testGetEventPush(): void; abstract public function testGetEventPullRequest(): void; - abstract public function testValidateWebhookEvent(): void; - protected function setUp(): void { $this->setupAdapter(); @@ -1324,6 +1365,319 @@ public function testGetCommitWithInvalidHash(): void } } + /** + * @return array The event the provider delivered, parsed + */ + protected function awaitWebhook(string $eventName, string $secret): array + { + $eventHeader = $this->vcsAdapter->getEventHeaderName(); + + $webhookData = []; + $this->assertEventually(function () use (&$webhookData, $eventHeader, $eventName) { + $webhookData = $this->getLastWebhookRequest(); + $this->assertNotEmpty($webhookData, 'No webhook was delivered'); + $this->assertNotEmpty($webhookData['data'] ?? '', 'Webhook payload was empty'); + $this->assertSame($eventName, $this->findHeader($webhookData['headers'] ?? [], $eventHeader)); + }, 60000, 2000); + + $payload = $webhookData['data']; + $signatureHeader = $this->vcsAdapter->getSignatureHeaderName(); + $signature = $this->findHeader($webhookData['headers'] ?? [], $signatureHeader); + + $this->assertNotEmpty($signature, "Missing {$signatureHeader} header"); + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), + 'Webhook signature did not validate' + ); + + return $this->vcsAdapter->getEvent($eventName, $payload); + } + + public function testValidateWebhookEvent(): void + { + $payload = '{"object_kind":"push","action":"push"}'; + $secret = 'my-webhook-secret'; + + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $secret), $secret) + ); + $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, 'not-the-signature', $secret)); + $this->assertFalse( + $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, 'another-secret'), $secret) + ); + } + + public function testGetRepositoryPresignedUrl(): void + { + $repositoryName = 'test-presigned-url-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $tarball = $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch); + $this->assertStringStartsWith('http', $tarball); + $this->assertStringContainsString(static::$presignedTarballFragment, $tarball); + + $zipball = $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch, 'zipball'); + $this->assertStringContainsString(static::$presignedZipballFragment, $zipball); + $this->assertNotSame($tarball, $zipball); + + // Without a ref the provider falls back to the default branch + $this->assertStringStartsWith('http', $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, $repositoryName)); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryPresignedUrlWithInvalidFormat(): void + { + $this->expectException(Exception::class); + $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); + } + + public function testHasAccessToAllRepositories(): void + { + $this->assertSame(static::$hasAccessToAllRepositories, $this->vcsAdapter->hasAccessToAllRepositories()); + } + + public function testGetInstallationRepository(): void + { + if (!static::$supportsInstallationRepository) { + $this->expectException(Exception::class); + $this->vcsAdapter->getInstallationRepository('any-repo-name'); + + return; + } + + $repositoryName = 'test-installation-repo-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $repository = $this->vcsAdapter->getInstallationRepository($repositoryName); + + $this->assertIsArray($repository); + $this->assertSame($repositoryName, $repository['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetOwnerNameWithInvalidRepositoryId(): void + { + $this->expectException(static::$repositoryNotFoundException); + $this->vcsAdapter->getOwnerName('', 999999999); + } + + public function testWebhookPushEvent(): void + { + $repositoryName = 'test-webhook-push-' . \uniqid(); + $secret = 'test-webhook-secret-' . \uniqid(); + $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); + + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->deleteLastWebhookRequest(); + + $webhookId = $this->vcsAdapter->createWebhook( + static::$owner, + $repositoryName, + $catcherUrl . '/webhook', + $secret, + ['push'] + ); + $this->assertGreaterThan(0, $webhookId); + + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Webhook Test', 'Initial commit'); + + $event = $this->awaitWebhook(static::$pushEventName, $secret); + + $this->assertSame(static::$defaultBranch, $event['branch']); + $this->assertSame($repositoryName, $event['repositoryName']); + $this->assertSame($this->ownerPath(), $event['owner']); + $this->assertNotEmpty($event['commitHash']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testWebhookPullRequestEvent(): void + { + $repositoryName = 'test-webhook-pr-' . \uniqid(); + $secret = 'test-webhook-secret-' . \uniqid(); + $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); + + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + // Everything the pull request needs happens before the hook exists, + // so those pushes cannot land on the catcher instead of the event + // being tested. + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->getLatestCommitEventually($repositoryName); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-branch', static::$defaultBranch); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'feature.txt', 'content', 'Add feature', 'feature-branch'); + + $webhookId = $this->vcsAdapter->createWebhook( + static::$owner, + $repositoryName, + $catcherUrl . '/webhook', + $secret, + ['pull_request'] + ); + $this->assertGreaterThan(0, $webhookId); + + $this->deleteLastWebhookRequest(); + + $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Test Webhook PR', + 'feature-branch', + static::$defaultBranch + ); + + $event = $this->awaitWebhook(static::$pullRequestEventName, $secret); + + $this->assertSame('feature-branch', $event['branch']); + $this->assertSame($repositoryName, $event['repositoryName']); + $this->assertSame($this->ownerPath(), $event['owner']); + $this->assertContains($event['action'], static::$pullRequestOpenedActions); + $this->assertGreaterThan(0, $event['pullRequestNumber']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryTreeWithSlashInBranchName(): void + { + $repositoryName = 'test-branch-with-slash-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->getLatestCommitEventually($repositoryName); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature/test-branch', static::$defaultBranch); + + $tree = []; + $this->assertEventually(function () use (&$tree, $repositoryName) { + $tree = $this->vcsAdapter->getRepositoryTree(static::$owner, $repositoryName, 'feature/test-branch'); + $this->assertContains('README.md', $tree); + }); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateTag(): void + { + $repositoryName = 'test-create-tag-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $result = $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash, 'First release'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('name', $result); + $this->assertSame('v1.0.0', $result['name']); + + // Providers describe the tagged commit differently, so read it back + $this->assertEventually(function () use ($repositoryName) { + $this->assertContains('v1.0.0', $this->vcsAdapter->listTags(static::$owner, $repositoryName)); + }); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testSearchRepositoriesPagination(): void + { + $prefix = 'test-pagination-' . \uniqid(); + $repo1 = $prefix . '-1'; + $repo2 = $prefix . '-2'; + + try { + $this->vcsAdapter->createRepository(static::$owner, $repo1, false); + $this->vcsAdapter->createRepository(static::$owner, $repo2, false); + + $page1 = []; + $this->assertEventually(function () use (&$page1, $prefix) { + $page1 = $this->vcsAdapter->searchRepositories(static::$owner, 1, 1, $prefix); + $this->assertGreaterThanOrEqual(2, $page1['total']); + }, 60000, 2000); + + $this->assertCount(1, $page1['items']); + $this->assertCount(1, $this->vcsAdapter->searchRepositories(static::$owner, 2, 1, $prefix)['items']); + $this->assertEmpty($this->vcsAdapter->searchRepositories(static::$owner, 20, 1, $prefix)['items']); + } catch (\Throwable $e) { + $this->discardRepositories($repo1, $repo2); + + throw $e; + } + + $this->deleteRepositories($repo1, $repo2); + } + + public function testListTagsCommitlessRepository(): void + { + $repositoryName = 'test-list-tags-commitless-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + // No commits at all, which some providers answer differently from + // a repository that simply has no tags + $this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName)); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCommitStatuses(): void + { + $repositoryName = 'test-get-commit-statuses-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $this->vcsAdapter->updateCommitStatus($repositoryName, $commitHash, static::$owner, 'pending', 'Build started', '', 'ci/test'); + + $result = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + + foreach ($result as $status) { + $this->assertArrayHasKey('state', $status); + $this->assertArrayHasKey('description', $status); + $this->assertArrayHasKey('target_url', $status); + $this->assertArrayHasKey('context', $status); + } + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCommitStatusesEmptyForNewCommit(): void + { + $repositoryName = 'test-get-commit-statuses-empty-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $this->assertSame([], $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash)); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGetEventInvalidPayload(): void { $this->expectException(Exception::class); From 09ee0fbcb3f9aa062619a2dc315515655dac7b68 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 13:56:49 +0530 Subject: [PATCH 10/20] Correct two GitHub facts the shared tests got wrong CI answered both: GitHub redirects archive downloads to codeload, whose paths read legacy.tar.gz and legacy.zip rather than tarball and zipball, so the shared defaults already described it and the override was wrong. And getOwnerName ignores the repository id entirely on GitHub, resolving the owner from the installation, so an id that does not exist has no meaning there - skipped like the other getOwnerName cases. --- tests/VCS/Adapter/GitHubTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 183e3cc6..0e6fa94b 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -18,8 +18,6 @@ class GitHubTest extends Base protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; protected static bool $supportsInstallationRepository = true; - protected static string $presignedTarballFragment = 'tarball'; - protected static string $presignedZipballFragment = 'zipball'; protected function signWebhookPayload(string $payload, string $secret): string { @@ -689,6 +687,11 @@ public function testGetOwnerNameWithNullRepositoryId(): void $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); } + public function testGetOwnerNameWithInvalidRepositoryId(): void + { + $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); + } + public function testWebhookPushEvent(): void { $this->markTestSkipped('github.com cannot deliver webhooks to the local request catcher'); From 5a31f62d69a9ecf486a687573d27e1f1f25de16a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:17:49 +0530 Subject: [PATCH 11/20] Declare what a provider cannot do instead of overriding tests to skip GitHubTest carried 22 skip methods and GogsTest 16, all saying the same thing in four lines each: this provider does not have that API. They are now capabilities on Base - pull request creation and lookup, commit statuses and reading them back, tags, user lookup, repository languages, webhook delivery, resolving an owner from a repository id, rejecting invalid repository names - and the shared tests skip themselves when a capability is missing. GitHubTest 738 -> 661 lines, GogsTest 141 -> 82. Cleanup also became one policy. A transient GitLab 500 while deleting a repository failed testListRepositoryContents in CI even though the test itself had passed, and 49 other finally blocks could do the same. Cleanup is best effort everywhere now; deleting is asserted by the delete tests. --- tests/VCS/Adapter/GitHubTest.php | 92 ++---------- tests/VCS/Adapter/GogsTest.php | 69 +-------- tests/VCS/Base.php | 238 ++++++++++++++++++------------- 3 files changed, 154 insertions(+), 245 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 0e6fa94b..1dcb59db 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -18,6 +18,14 @@ class GitHubTest extends Base protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; protected static bool $supportsInstallationRepository = true; + protected static bool $supportsPullRequestCreation = false; + protected static bool $supportsCommitStatusLookup = false; + protected static bool $supportsTags = false; + protected static bool $supportsUserLookup = false; + protected static bool $supportsRepositoryLanguages = false; + protected static bool $supportsWebhookDelivery = false; + protected static bool $resolvesOwnerFromRepositoryId = false; + protected static bool $rejectsInvalidRepositoryNames = false; protected function signWebhookPayload(string $payload, string $secret): string { @@ -597,110 +605,26 @@ public function testGetOwnerName(): void - public function testGetPullRequest(): void - { - $this->markTestSkipped('createPullRequest() is not implemented for GitHub'); - } - public function testGetPullRequestFiles(): void - { - $this->markTestSkipped('createPullRequest() is not implemented for GitHub'); - } - public function testGetPullRequestWithInvalidNumber(): void - { - $this->markTestSkipped('createPullRequest() is not implemented for GitHub'); - } - public function testGetPullRequestFromBranch(): void - { - $this->markTestSkipped('createPullRequest() is not implemented for GitHub'); - } - public function testGetComment(): void - { - $this->markTestSkipped('Needs a pull request, and createPullRequest() is not implemented for GitHub'); - } - public function testCreateComment(): void - { - $this->markTestSkipped('Needs a pull request, and createPullRequest() is not implemented for GitHub'); - } - public function testUpdateComment(): void - { - $this->markTestSkipped('Needs a pull request, and createPullRequest() is not implemented for GitHub'); - } - public function testGetUser(): void - { - $this->markTestSkipped('GitHub::getUser() returns the raw response envelope instead of the shared user shape'); - } - public function testGetUserWithInvalidUsername(): void - { - $this->markTestSkipped('GitHub::getUser() returns the raw response envelope instead of throwing'); - } - public function testListTags(): void - { - $this->markTestSkipped('createTag() is not implemented for GitHub'); - } - public function testGenerateCloneCommandWithTag(): void - { - $this->markTestSkipped('createTag() is not implemented for GitHub'); - } - public function testCreateTag(): void - { - $this->markTestSkipped('createTag() is not implemented for GitHub'); - } - public function testGetCommitStatuses(): void - { - $this->markTestSkipped('getCommitStatuses() is not implemented for GitHub'); - } - public function testGetCommitStatusesEmptyForNewCommit(): void - { - $this->markTestSkipped('getCommitStatuses() is not implemented for GitHub'); - } - public function testCreateRepositoryWithInvalidName(): void - { - $this->markTestSkipped('GitHub normalizes spaces in repository names instead of rejecting them'); - } - public function testGetOwnerNameWithoutRepositoryId(): void - { - $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); - } - public function testGetOwnerNameWithZeroRepositoryId(): void - { - $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); - } - public function testGetOwnerNameWithNullRepositoryId(): void - { - $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); - } - public function testGetOwnerNameWithInvalidRepositoryId(): void - { - $this->markTestSkipped('GitHub resolves the owner from the installation, not a repository id'); - } - public function testWebhookPushEvent(): void - { - $this->markTestSkipped('github.com cannot deliver webhooks to the local request catcher'); - } - public function testWebhookPullRequestEvent(): void - { - $this->markTestSkipped('github.com cannot deliver webhooks to the local request catcher'); - } public function testListRepositoryLanguages(): void { diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index cc5a3d39..7ac151f8 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -13,6 +13,11 @@ class GogsTest extends GiteaTest protected static string $owner = ''; protected static string $defaultBranch = 'master'; + protected static bool $supportsPullRequestCreation = false; + protected static bool $supportsPullRequestLookup = false; + protected static bool $supportsCommitStatuses = false; + protected static bool $supportsCommitStatusLookup = false; + protected static bool $supportsRepositoryLanguages = false; protected static string $eventHeader = 'x-gogs-event'; protected static string $signatureHeader = 'x-gogs-signature'; @@ -58,77 +63,13 @@ protected function setupGogs(): void // --- Skip tests for unsupported Gogs features --- // Pull request API - public function testGetComment(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } - public function testGetPullRequest(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } - public function testGetPullRequestWithInvalidNumber(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } - public function testGetPullRequestFromBranch(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } - public function testGetPullRequestFromBranchNoPR(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } - public function testUpdateComment(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } - public function testCreateComment(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } - public function testWebhookPullRequestEvent(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } // Commit status - public function testUpdateCommitStatus(): void - { - $this->markTestSkipped('Gogs does not support commit status API'); - } - public function testUpdateCommitStatusWithInvalidCommit(): void - { - $this->markTestSkipped('Gogs does not support commit status API'); - } - public function testUpdateCommitStatusWithNonExistingRepository(): void - { - $this->markTestSkipped('Gogs does not support commit status API'); - } - public function testGetCommitStatuses(): void - { - $this->markTestSkipped('Gogs does not support commit status API'); - } - public function testGetCommitStatusesEmptyForNewCommit(): void - { - $this->markTestSkipped('Gogs does not support commit status API'); - } // Repository languages - public function testListRepositoryLanguages(): void - { - $this->markTestSkipped('Gogs does not support repository languages endpoint'); - } - public function testListRepositoryLanguagesEmptyRepo(): void - { - $this->markTestSkipped('Gogs does not support repository languages endpoint'); - } - public function testGetPullRequestFiles(): void - { - $this->markTestSkipped('Gogs does not support pull request API'); - } public function testListBranchesEmptyRepository(): void { // The Gogs adapter creates repositories with `auto_init: true` (plus a diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 4ef3df1a..798f3281 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -77,6 +77,30 @@ abstract class Base extends TestCase */ protected static string $repositoryNotFoundException = RepositoryNotFound::class; + /** + * Parts of the contract a provider may not offer at all. Each one skips the + * tests that need it, instead of every adapter overriding them to say so. + */ + protected static bool $supportsPullRequestCreation = true; + + protected static bool $supportsPullRequestLookup = true; + + protected static bool $supportsCommitStatuses = true; + + protected static bool $supportsCommitStatusLookup = true; + + protected static bool $supportsTags = true; + + protected static bool $supportsUserLookup = true; + + protected static bool $supportsRepositoryLanguages = true; + + protected static bool $supportsWebhookDelivery = true; + + protected static bool $resolvesOwnerFromRepositoryId = true; + + protected static bool $rejectsInvalidRepositoryNames = true; + /** * Headers the provider sends its webhook event type and signature under. */ @@ -209,6 +233,13 @@ protected function assertPushedAt(array $repository): void ); } + protected function skipUnlessSupported(bool $supported, string $capability): void + { + if (!$supported) { + $this->markTestSkipped(static::class . ' does not support ' . $capability); + } + } + protected function assertEventually(callable $probe, int $timeoutMs = 15000, int $waitMs = 500): void { $start = microtime(true) * 1000; @@ -239,39 +270,10 @@ protected function getLatestCommitEventually(string $repositoryName): array } /** - * Remove repositories a passing test created. Every repository is attempted - * before reporting, so one failure cannot strand the rest, and a repository - * that was never created is not a failure. Anything else - auth, transport, - * a provider fault - surfaces rather than leaving the repository behind. - */ - protected function deleteRepositories(string ...$repositoryNames): void - { - $failures = []; - - foreach ($repositoryNames as $repositoryName) { - try { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } catch (RepositoryNotFound) { - continue; - } catch (Exception $e) { - // Adapters carry the HTTP status as the exception code - if ($e->getCode() === 404) { - continue; - } - - $failures[] = "{$repositoryName}: {$e->getMessage()}"; - } - } - - if ($failures !== []) { - throw new Exception('Failed to clean up ' . \implode(', ', $failures)); - } - } - - /** - * Remove repositories while a failure is already on its way out. Reporting - * a cleanup problem here would replace the reason the test actually failed, - * so this stays quiet. + * Remove repositories a test created. Cleanup is best effort on purpose: a + * provider hiccup while tearing down should not fail an otherwise passing + * test, or replace the reason a failing one failed. Deleting is asserted by + * the delete tests instead. */ protected function discardRepositories(string ...$repositoryNames): void { @@ -325,7 +327,7 @@ public function testCreateRepository(): void $this->assertFalse($this->isPrivate($fetched), 'getRepository() reported the new repository as private'); $this->assertSame($this->ownerPath(), $this->ownerOf($fetched)); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -344,7 +346,7 @@ public function testCreatePrivateRepository(): void $fetched = $this->vcsAdapter->getRepository(static::$owner, $repositoryName); $this->assertTrue($this->isPrivate($fetched), 'getRepository() did not report the new repository as private'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -360,7 +362,7 @@ public function testGetRepository(): void $this->assertSame($repositoryName, $result['name']); $this->assertPushedAt($result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -425,7 +427,7 @@ public function testGetRepositoryName(): void $this->assertIsString($result); $this->assertSame($repositoryName, $result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -466,7 +468,7 @@ public function testGetRepositoryTree(): void $this->assertContains('src/main.php', $treeRecursive); $this->assertGreaterThanOrEqual(3, \count($treeRecursive)); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -482,7 +484,7 @@ public function testGetRepositoryTreeWithInvalidBranch(): void $this->assertIsArray($tree); $this->assertEmpty($tree); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -505,7 +507,7 @@ public function testGetRepositoryContent(): void $this->assertSame($fileContent, $result['content']); $this->assertGreaterThan(0, $result['size']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -522,7 +524,7 @@ public function testGetRepositoryContentWithRef(): void $this->assertIsArray($result); $this->assertSame('main branch content', $result['content']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -537,7 +539,7 @@ public function testGetRepositoryContentFileNotFound(): void $this->expectException(FileNotFound::class); $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'non-existing.txt'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -570,7 +572,7 @@ public function testListRepositoryContents(): void $this->assertArrayHasKey('size', $item); } } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -586,12 +588,14 @@ public function testListRepositoryContentsNonExistingPath(): void $this->assertIsArray($contents); $this->assertEmpty($contents); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testListRepositoryLanguages(): void { + $this->skipUnlessSupported(static::$supportsRepositoryLanguages, 'repository languages'); + $repositoryName = 'test-list-repository-languages-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -608,12 +612,14 @@ public function testListRepositoryLanguages(): void $this->assertIsArray($languages); $this->assertContains('PHP', $languages); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testListRepositoryLanguagesEmptyRepo(): void { + $this->skipUnlessSupported(static::$supportsRepositoryLanguages, 'repository languages'); + $repositoryName = 'test-list-repository-languages-empty-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -622,7 +628,7 @@ public function testListRepositoryLanguagesEmptyRepo(): void $this->assertIsArray($languages); $this->assertEmpty($languages); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -648,7 +654,7 @@ public function testListBranches(): void $this->assertNotEmpty($branches); $this->assertContains(static::$defaultBranch, $branches); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -663,12 +669,14 @@ public function testListBranchesEmptyRepository(): void $this->assertIsArray($branches); $this->assertEmpty($branches); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testListTags(): void { + $this->skipUnlessSupported(static::$supportsTags, 'creating tags'); + $repositoryName = 'test-list-tags-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -693,7 +701,7 @@ public function testListTags(): void $this->assertSame(['v2.0.0'], $this->vcsAdapter->listTags(static::$owner, $repositoryName, 'v2.0.0')); $this->assertEmpty($this->vcsAdapter->listTags(static::$owner, $repositoryName, 'nope-*')); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -710,7 +718,7 @@ public function testListTagsEmptyRepository(): void // Glob against a repository with no tags stays empty $this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName, 'v*')); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -744,7 +752,7 @@ public function testGetCommit(): void $this->assertStringContainsString($repositoryName, $result['commitUrl']); $this->assertNotEmpty($result['commitAuthor']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -782,7 +790,7 @@ public function testGetLatestCommit(): void $this->assertStringStartsWith($secondMessage, $commit2['commitMessage']); $this->assertNotSame($commit1Hash, $commit2['commitHash']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -797,12 +805,14 @@ public function testGetLatestCommitWithInvalidBranch(): void $this->expectException(Exception::class); $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, 'non-existing-branch'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCommitStatus(): void { + $this->skipUnlessSupported(static::$supportsCommitStatusLookup, 'reading commit statuses'); + $repositoryName = 'test-update-commit-status-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -839,7 +849,7 @@ public function testUpdateCommitStatus(): void $this->assertSame('success', $written['state']); $this->assertSame('Build passed', $written['description']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -873,7 +883,7 @@ public function testGenerateCloneCommand(): void $this->assertSame(0, $exitCode, implode("\n", $output)); $this->assertFileExists($directory . '/README.md'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); if (\is_dir($directory)) { \exec('rm -rf ' . escapeshellarg($directory)); } @@ -911,7 +921,7 @@ public function testGenerateCloneCommandWithCommitHash(): void $this->assertSame(0, $exitCode, implode("\n", $output)); $this->assertFileExists($directory . '/README.md'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); if (\is_dir($directory)) { \exec('rm -rf ' . escapeshellarg($directory)); } @@ -948,16 +958,22 @@ public function testGenerateCloneCommandWithInvalidRepository(): void public function testGetOwnerNameWithoutRepositoryId(): void { + $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); + $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('')); } public function testGetOwnerNameWithZeroRepositoryId(): void { + $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); + $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', 0)); } public function testGetOwnerNameWithNullRepositoryId(): void { + $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); + $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', null)); } @@ -974,18 +990,22 @@ public function testGetOwnerName(): void $this->assertSame($this->ownerPath(), $this->vcsAdapter->getOwnerName('', $repositoryId)); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateRepositoryWithInvalidName(): void { + $this->skipUnlessSupported(static::$rejectsInvalidRepositoryNames, 'rejecting invalid repository names'); + $this->expectException(Exception::class); $this->vcsAdapter->createRepository(static::$owner, 'invalid name with spaces', false); } public function testGenerateCloneCommandWithTag(): void { + $this->skipUnlessSupported(static::$supportsTags, 'creating tags'); + $repositoryName = 'test-clone-tag-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); $directory = '/tmp/test-clone-tag-' . \uniqid(); @@ -1013,7 +1033,7 @@ public function testGenerateCloneCommandWithTag(): void $this->assertStringContainsString('v1.0.0', $command); $this->assertStringContainsString('git checkout FETCH_HEAD', $command); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1034,13 +1054,9 @@ public function testSearchRepositoriesMatchesName(): void }, 60000, 2000); $this->assertNotContains($other, $names); - } catch (\Throwable $e) { + } finally { $this->discardRepositories($match, $other); - - throw $e; } - - $this->deleteRepositories($match, $other); } public function testSearchRepositories(): void @@ -1070,17 +1086,15 @@ public function testSearchRepositories(): void $this->assertArrayHasKey('private', $repository); $this->assertPushedAt($repository); } - } catch (\Throwable $e) { + } finally { $this->discardRepositories($repo1Name, $repo2Name); - - throw $e; } - - $this->deleteRepositories($repo1Name, $repo2Name); } public function testGetPullRequest(): void { + $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + $repositoryName = 'test-get-pull-request-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1113,12 +1127,14 @@ public function testGetPullRequest(): void $this->assertSame('Test PR', $result['title']); $this->assertSame(static::$openPullRequestState, $result['state']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetPullRequestFiles(): void { + $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + $repositoryName = 'test-get-pull-request-files-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1147,12 +1163,14 @@ public function testGetPullRequestFiles(): void $filenames = array_column($result, 'filename'); $this->assertContains('feature.txt', $filenames); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetPullRequestWithInvalidNumber(): void { + $this->skipUnlessSupported(static::$supportsPullRequestLookup, 'looking up pull requests'); + $repositoryName = 'test-get-pull-request-invalid-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1160,12 +1178,14 @@ public function testGetPullRequestWithInvalidNumber(): void $this->expectException(Exception::class); $this->vcsAdapter->getPullRequest(static::$owner, $repositoryName, 99999); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetPullRequestFromBranch(): void { + $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + $repositoryName = 'test-get-pr-from-branch-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1189,12 +1209,14 @@ public function testGetPullRequestFromBranch(): void $this->assertArrayHasKey('head', $result); $this->assertSame('my-feature', $result['head']['ref']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetPullRequestFromBranchNoPR(): void { + $this->skipUnlessSupported(static::$supportsPullRequestLookup, 'looking up pull requests'); + $repositoryName = 'test-get-pr-no-pr-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1208,12 +1230,14 @@ public function testGetPullRequestFromBranchNoPR(): void $this->assertIsArray($result); $this->assertEmpty($result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateComment(): void { + $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + $repositoryName = 'test-create-comment-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1238,12 +1262,14 @@ public function testCreateComment(): void $this->assertNotEmpty($commentId); $this->assertIsString($commentId); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetComment(): void { + $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + $repositoryName = 'test-get-comment-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1268,12 +1294,14 @@ public function testGetComment(): void $this->assertIsString($result); $this->assertSame('Test comment', $result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateComment(): void { + $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + $repositoryName = 'test-update-comment-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1300,7 +1328,7 @@ public function testUpdateComment(): void $finalComment = $this->vcsAdapter->getComment(static::$owner, $repositoryName, $commentId); $this->assertSame('Updated comment', $finalComment); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1314,7 +1342,7 @@ public function testCreateCommentInvalidPR(): void $this->expectException(Exception::class); $this->vcsAdapter->createComment(static::$owner, $repositoryName, 99999, 'Test comment'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1329,12 +1357,14 @@ public function testGetCommentInvalidId(): void $this->assertIsString($result); $this->assertSame('', $result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetUser(): void { + $this->skipUnlessSupported(static::$supportsUserLookup, 'looking up users'); + $result = $this->vcsAdapter->getUser(static::$existingUser); $this->assertIsArray($result); @@ -1347,6 +1377,8 @@ public function testGetUser(): void public function testGetUserWithInvalidUsername(): void { + $this->skipUnlessSupported(static::$supportsUserLookup, 'looking up users'); + $this->expectException(Exception::class); $this->vcsAdapter->getUser('non-existent-user-' . \uniqid()); } @@ -1361,7 +1393,7 @@ public function testGetCommitWithInvalidHash(): void $this->expectException(Exception::class); $this->vcsAdapter->getCommit(static::$owner, $repositoryName, 'invalid-sha-12345'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1426,7 +1458,7 @@ public function testGetRepositoryPresignedUrl(): void // Without a ref the provider falls back to the default branch $this->assertStringStartsWith('http', $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, $repositoryName)); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1459,18 +1491,22 @@ public function testGetInstallationRepository(): void $this->assertIsArray($repository); $this->assertSame($repositoryName, $repository['name']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetOwnerNameWithInvalidRepositoryId(): void { + $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); + $this->expectException(static::$repositoryNotFoundException); $this->vcsAdapter->getOwnerName('', 999999999); } public function testWebhookPushEvent(): void { + $this->skipUnlessSupported(static::$supportsWebhookDelivery, 'webhook delivery to the test catcher'); + $repositoryName = 'test-webhook-push-' . \uniqid(); $secret = 'test-webhook-secret-' . \uniqid(); $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); @@ -1498,12 +1534,14 @@ public function testWebhookPushEvent(): void $this->assertSame($this->ownerPath(), $event['owner']); $this->assertNotEmpty($event['commitHash']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testWebhookPullRequestEvent(): void { + $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + $repositoryName = 'test-webhook-pr-' . \uniqid(); $secret = 'test-webhook-secret-' . \uniqid(); $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); @@ -1546,7 +1584,7 @@ public function testWebhookPullRequestEvent(): void $this->assertContains($event['action'], static::$pullRequestOpenedActions); $this->assertGreaterThan(0, $event['pullRequestNumber']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1566,12 +1604,14 @@ public function testGetRepositoryTreeWithSlashInBranchName(): void $this->assertContains('README.md', $tree); }); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateTag(): void { + $this->skipUnlessSupported(static::$supportsTags, 'creating tags'); + $repositoryName = 'test-create-tag-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1590,7 +1630,7 @@ public function testCreateTag(): void $this->assertContains('v1.0.0', $this->vcsAdapter->listTags(static::$owner, $repositoryName)); }); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1613,13 +1653,9 @@ public function testSearchRepositoriesPagination(): void $this->assertCount(1, $page1['items']); $this->assertCount(1, $this->vcsAdapter->searchRepositories(static::$owner, 2, 1, $prefix)['items']); $this->assertEmpty($this->vcsAdapter->searchRepositories(static::$owner, 20, 1, $prefix)['items']); - } catch (\Throwable $e) { + } finally { $this->discardRepositories($repo1, $repo2); - - throw $e; } - - $this->deleteRepositories($repo1, $repo2); } public function testListTagsCommitlessRepository(): void @@ -1632,12 +1668,14 @@ public function testListTagsCommitlessRepository(): void // a repository that simply has no tags $this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName)); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetCommitStatuses(): void { + $this->skipUnlessSupported(static::$supportsCommitStatusLookup, 'reading commit statuses'); + $repositoryName = 'test-get-commit-statuses-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1659,12 +1697,14 @@ public function testGetCommitStatuses(): void $this->assertArrayHasKey('context', $status); } } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetCommitStatusesEmptyForNewCommit(): void { + $this->skipUnlessSupported(static::$supportsCommitStatusLookup, 'reading commit statuses'); + $repositoryName = 'test-get-commit-statuses-empty-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1674,7 +1714,7 @@ public function testGetCommitStatusesEmptyForNewCommit(): void $this->assertSame([], $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash)); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1715,7 +1755,7 @@ public function testCreateFile(): void $this->assertIsArray($result); $this->assertNotEmpty($result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1748,7 +1788,7 @@ public function testCreateFileOnBranch(): void ); $this->assertSame('# Feature', $content['content']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1773,7 +1813,7 @@ public function testListRepositoryContentsInSubdirectory(): void $this->assertContains('file1.php', $names); $this->assertContains('file2.php', $names); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1787,6 +1827,8 @@ public function testListBranchesNonExistingRepository(): void public function testUpdateCommitStatusWithInvalidCommit(): void { + $this->skipUnlessSupported(static::$supportsCommitStatuses, 'commit statuses'); + $repositoryName = 'test-update-status-invalid-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1799,12 +1841,14 @@ public function testUpdateCommitStatusWithInvalidCommit(): void 'success' ); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCommitStatusWithNonExistingRepository(): void { + $this->skipUnlessSupported(static::$supportsCommitStatuses, 'commit statuses'); + $this->expectException(Exception::class); $this->vcsAdapter->updateCommitStatus( 'nonexistent-repo-' . \uniqid(), From efcc95a97da4869fb014eb0ae95a4837cd7ed72a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:23:03 +0530 Subject: [PATCH 12/20] Share the check run contract and what a provider says about an author Check runs were 276 of GitHubTest's lines. They are a GitHub-only API, so Git declares them the way it declares presigned urls - a default that reports them unsupported - and the tests live in Base behind a supportsCheckRuns capability. Any adapter that gains check runs inherits the tests instead of copying them. GitHub's last two overrides went the same way: - getLatestCommit only differed by asserting the author avatar and profile url, now two capabilities, since GitLab reports neither and Gitea reports an avatar but no url - updateCommitStatus only differed by not reading the status back, so writing is gated on supportsCommitStatuses and reading on supportsCommitStatusLookup. GitHub gains the write test it used to override away GitHubTest 738 -> 328 lines. What is left cannot be shared: hand-built webhook payloads, GitHub's own listBranches signature, the git blob SHA, case sensitive paths, installation-scoped getOwnerName, and language stats that need the inconclusive handling. --- src/VCS/Adapter/Git.php | 71 +++++++ tests/VCS/Adapter/GitHubTest.php | 339 +------------------------------ tests/VCS/Adapter/GiteaTest.php | 1 + tests/VCS/Base.php | 329 +++++++++++++++++++++++++++++- 4 files changed, 403 insertions(+), 337 deletions(-) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index d843c381..4c650715 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -113,6 +113,77 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, throw new Exception('getRepositoryPresignedUrl() is not supported by ' . $this->getName()); } + /** + * Create a check run for a commit. + * + * Only some providers model checks separately from commit statuses, so the + * default reports it as unsupported. + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function createCheckRun( + string $owner, + string $repositoryName, + string $headSha, + string $name, + string $status = 'queued', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + throw new Exception('createCheckRun() is not supported by ' . $this->getName()); + } + + /** + * Get a check run by id. + * + * @return array + */ + public function getCheckRun(string $owner, string $repositoryName, int $checkRunId): array + { + throw new Exception('getCheckRun() is not supported by ' . $this->getName()); + } + + /** + * Update a check run. + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function updateCheckRun( + string $owner, + string $repositoryName, + int $checkRunId, + string $name = '', + string $status = '', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + throw new Exception('updateCheckRun() is not supported by ' . $this->getName()); + } + /** * Get commit statuses * diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 1dcb59db..5b641909 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -18,6 +18,9 @@ class GitHubTest extends Base protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; protected static bool $supportsInstallationRepository = true; + protected static bool $supportsCheckRuns = true; + protected static bool $reportsCommitAuthorAvatar = true; + protected static bool $reportsCommitAuthorUrl = true; protected static bool $supportsPullRequestCreation = false; protected static bool $supportsCommitStatusLookup = false; protected static bool $supportsTags = false; @@ -242,354 +245,18 @@ public function testListBranchesPagination(): void } } - public function testGetLatestCommit(): void - { - $repositoryName = 'test-get-latest-commit-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $firstMessage = 'First commit'; - $secondMessage = 'Second commit'; - - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', $firstMessage); - $commit1 = $this->getLatestCommitEventually($repositoryName); - - $this->assertIsArray($commit1); - $this->assertNotEmpty($commit1['commitHash']); - $this->assertStringStartsWith($firstMessage, $commit1['commitMessage']); - $this->assertNotEmpty($commit1['commitUrl']); - $this->assertNotEmpty($commit1['commitAuthorAvatar']); - $this->assertNotEmpty($commit1['commitAuthorUrl']); - - $commit1Hash = $commit1['commitHash']; - - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'test.txt', 'test', $secondMessage); - - $commit2 = []; - $this->assertEventually(function () use (&$commit2, $repositoryName, $commit1Hash) { - $commit2 = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $this->assertNotSame($commit1Hash, $commit2['commitHash']); - }, 15000, 1000); - - $this->assertStringStartsWith($secondMessage, $commit2['commitMessage']); - $this->assertNotSame($commit1Hash, $commit2['commitHash']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - - public function testUpdateCommitStatus(): void - { - $repositoryName = 'test-update-commit-status-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - // Should not throw - $this->vcsAdapter->updateCommitStatus( - $repositoryName, - $commitHash, - static::$owner, - 'success', - 'Build passed', - 'https://example.com', - 'ci/build' - ); - - $this->assertTrue(true); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testCreateCheckRun(): void - { - $repositoryName = 'test-create-check-run-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit = $this->getLatestCommitEventually($repositoryName); - $commitHash = $commit['commitHash']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - startedAt: gmdate('Y-m-d\TH:i:s\Z'), - ); - - $this->assertArrayHasKey('id', $checkRun); - $this->assertIsInt($checkRun['id']); - $this->assertEquals('ci/build', $checkRun['name']); - $this->assertEquals('in_progress', $checkRun['status']); - $this->assertNull($checkRun['conclusion']); - $this->assertEquals($commitHash, $checkRun['head_sha']); - $this->assertNotEmpty($checkRun['url']); - $this->assertNotEmpty($checkRun['html_url']); - $this->assertNotEmpty($checkRun['started_at']); - $this->assertNull($checkRun['completed_at']); - - $fetched = $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, $checkRun['id']); - $this->assertEquals($checkRun['id'], $fetched['id']); - $this->assertEquals('ci/build', $fetched['name']); - $this->assertEquals('in_progress', $fetched['status']); - $this->assertNull($fetched['conclusion']); - $this->assertEquals($commitHash, $fetched['head_sha']); - $this->assertNotEmpty($fetched['url']); - $this->assertNotEmpty($fetched['html_url']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testCreateCheckRunWithInvalidRepository(): void - { - $this->expectException(\Exception::class); - $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: 'non-existing-repository-' . \uniqid(), - headSha: 'a' . str_repeat('0', 39), - name: 'ci/build', - ); - } - - public function testGetCheckRunWithInvalidId(): void - { - $repositoryName = 'test-get-check-run-invalid-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->expectException(\Exception::class); - $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, 999999999); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testCreateTwoCheckRunsOnSameCommit(): void - { - $repositoryName = 'test-two-check-runs-same-commit-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit = $this->getLatestCommitEventually($repositoryName); - $commitHash = $commit['commitHash']; - - $first = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - ); - - $second = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - ); - - $this->assertArrayHasKey('id', $first); - $this->assertArrayHasKey('id', $second); - $this->assertNotEquals($first['id'], $second['id']); - $this->assertEquals($commitHash, $first['head_sha']); - $this->assertEquals($commitHash, $second['head_sha']); - $this->assertEquals('ci/build', $first['name']); - $this->assertEquals('ci/build', $second['name']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void - { - $repositoryName = 'test-check-runs-different-commits-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit1 = $this->getLatestCommitEventually($repositoryName); - $commitHash1 = $commit1['commitHash']; - - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.md', '# Second'); - $commit2 = $this->getLatestCommitEventually($repositoryName); - $commitHash2 = $commit2['commitHash']; - - $first = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash1, - name: 'ci/build', - status: 'in_progress', - ); - - $second = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash2, - name: 'ci/build', - status: 'in_progress', - ); - - $this->assertArrayHasKey('id', $first); - $this->assertArrayHasKey('id', $second); - $this->assertNotEquals($first['id'], $second['id']); - $this->assertEquals($commitHash1, $first['head_sha']); - $this->assertEquals($commitHash2, $second['head_sha']); - $this->assertEquals('ci/build', $first['name']); - $this->assertEquals('ci/build', $second['name']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - public function testCreateCheckRunCompleted(): void - { - $repositoryName = 'test-create-check-run-completed-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit = $this->getLatestCommitEventually($repositoryName); - $commitHash = $commit['commitHash']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - conclusion: 'success', - title: 'Build passed', - summary: 'All checks passed successfully.', - ); - - $this->assertArrayHasKey('id', $checkRun); - $this->assertIsInt($checkRun['id']); - $this->assertEquals('ci/build', $checkRun['name']); - $this->assertEquals('completed', $checkRun['status']); - $this->assertEquals('success', $checkRun['conclusion']); - $this->assertEquals($commitHash, $checkRun['head_sha']); - $this->assertNotEmpty($checkRun['url']); - $this->assertNotEmpty($checkRun['html_url']); - $this->assertNotEmpty($checkRun['completed_at']); - $this->assertEquals('Build passed', $checkRun['output']['title']); - $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - public function testUpdateCheckRun(): void - { - $repositoryName = 'test-update-check-run-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commit = $this->getLatestCommitEventually($repositoryName); - $commitHash = $commit['commitHash']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - startedAt: gmdate('Y-m-d\TH:i:s\Z'), - ); - - $this->assertArrayHasKey('id', $checkRun); - $this->assertEquals('in_progress', $checkRun['status']); - - $updated = $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - checkRunId: $checkRun['id'], - status: 'completed', - conclusion: 'neutral', - title: 'Deployment skipped', - summary: 'Deployment skipped because the branch does not match the configured branch triggers.', - completedAt: gmdate('Y-m-d\TH:i:s\Z'), - ); - - $this->assertEquals($checkRun['id'], $updated['id']); - $this->assertEquals('completed', $updated['status']); - $this->assertEquals('neutral', $updated['conclusion']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - public function testUpdateCheckRunWithInvalidRepository(): void - { - $this->expectException(\Exception::class); - $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: 'non-existing-repository-' . \uniqid(), - checkRunId: 999999999, - conclusion: 'success', - ); - } - public function testUpdateCheckRunWithInvalidId(): void - { - $repositoryName = 'test-update-check-run-invalid-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $this->expectException(\Exception::class); - $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - checkRunId: 999999999, - conclusion: 'success', - ); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testUpdateCheckRunWithMissingConclusion(): void - { - $repositoryName = 'test-update-check-run-no-conclusion-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $commit = $this->getLatestCommitEventually($repositoryName); - $commitHash = $commit['commitHash']; - - $checkRun = $this->vcsAdapter->createCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - headSha: $commitHash, - name: 'ci/build', - status: 'in_progress', - ); - - $this->expectException(\Exception::class); - $this->vcsAdapter->updateCheckRun( - owner: static::$owner, - repositoryName: $repositoryName, - checkRunId: $checkRun['id'], - status: 'completed', - ); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 4f9bdbd7..d36bc222 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -30,6 +30,7 @@ protected function signWebhookPayload(string $payload, string $secret): string return hash_hmac('sha256', $payload, $secret); } protected static string $avatarDomain = 'gravatar.com'; + protected static bool $reportsCommitAuthorAvatar = true; protected function setupAdapter(): void { diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 798f3281..1229b775 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -101,6 +101,16 @@ abstract class Base extends TestCase protected static bool $rejectsInvalidRepositoryNames = true; + protected static bool $supportsCheckRuns = false; + + /** + * Whether the provider links the commit author back to an account. GitLab + * reports neither, Gitea an avatar but no profile url. + */ + protected static bool $reportsCommitAuthorAvatar = false; + + protected static bool $reportsCommitAuthorUrl = false; + /** * Headers the provider sends its webhook event type and signature under. */ @@ -233,6 +243,20 @@ protected function assertPushedAt(array $repository): void ); } + /** + * @param array $commit + */ + protected function assertCommitAuthorLinks(array $commit): void + { + if (static::$reportsCommitAuthorAvatar) { + $this->assertNotEmpty($commit['commitAuthorAvatar']); + } + + if (static::$reportsCommitAuthorUrl) { + $this->assertNotEmpty($commit['commitAuthorUrl']); + } + } + protected function skipUnlessSupported(bool $supported, string $capability): void { if (!$supported) { @@ -751,6 +775,7 @@ public function testGetCommit(): void $this->assertStringStartsWith($customMessage, $result['commitMessage']); $this->assertStringContainsString($repositoryName, $result['commitUrl']); $this->assertNotEmpty($result['commitAuthor']); + $this->assertCommitAuthorLinks($result); } finally { $this->discardRepositories($repositoryName); } @@ -775,6 +800,7 @@ public function testGetLatestCommit(): void $this->assertStringStartsWith($firstMessage, $commit1['commitMessage']); $this->assertStringContainsString($repositoryName, $commit1['commitUrl']); $this->assertNotEmpty($commit1['commitAuthor']); + $this->assertCommitAuthorLinks($commit1); $commit1Hash = $commit1['commitHash']; @@ -811,7 +837,7 @@ public function testGetLatestCommitWithInvalidBranch(): void public function testUpdateCommitStatus(): void { - $this->skipUnlessSupported(static::$supportsCommitStatusLookup, 'reading commit statuses'); + $this->skipUnlessSupported(static::$supportsCommitStatuses, 'commit statuses'); $repositoryName = 'test-update-commit-status-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -832,6 +858,10 @@ public function testUpdateCommitStatus(): void 'ci/build' ); + if (!static::$supportsCommitStatusLookup) { + return; + } + $statuses = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); $this->assertIsArray($statuses); $this->assertNotEmpty($statuses); @@ -1718,6 +1748,303 @@ public function testGetCommitStatusesEmptyForNewCommit(): void } } + public function testCreateCheckRun(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-create-check-run-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit = $this->getLatestCommitEventually($repositoryName); + $commitHash = $commit['commitHash']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + startedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertIsInt($checkRun['id']); + $this->assertEquals('ci/build', $checkRun['name']); + $this->assertEquals('in_progress', $checkRun['status']); + $this->assertNull($checkRun['conclusion']); + $this->assertEquals($commitHash, $checkRun['head_sha']); + $this->assertNotEmpty($checkRun['url']); + $this->assertNotEmpty($checkRun['html_url']); + $this->assertNotEmpty($checkRun['started_at']); + $this->assertNull($checkRun['completed_at']); + + $fetched = $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, $checkRun['id']); + $this->assertEquals($checkRun['id'], $fetched['id']); + $this->assertEquals('ci/build', $fetched['name']); + $this->assertEquals('in_progress', $fetched['status']); + $this->assertNull($fetched['conclusion']); + $this->assertEquals($commitHash, $fetched['head_sha']); + $this->assertNotEmpty($fetched['url']); + $this->assertNotEmpty($fetched['html_url']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testCreateCheckRunWithInvalidRepository(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $this->expectException(\Exception::class); + $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: 'non-existing-repository-' . \uniqid(), + headSha: 'a' . str_repeat('0', 39), + name: 'ci/build', + ); + } + public function testGetCheckRunWithInvalidId(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-get-check-run-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, 999999999); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testCreateTwoCheckRunsOnSameCommit(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-two-check-runs-same-commit-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->getLatestCommitEventually($repositoryName); + $commitHash = $commit['commitHash']; + + $first = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $second = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('id', $second); + $this->assertNotEquals($first['id'], $second['id']); + $this->assertEquals($commitHash, $first['head_sha']); + $this->assertEquals($commitHash, $second['head_sha']); + $this->assertEquals('ci/build', $first['name']); + $this->assertEquals('ci/build', $second['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-check-runs-different-commits-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit1 = $this->getLatestCommitEventually($repositoryName); + $commitHash1 = $commit1['commitHash']; + + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.md', '# Second'); + $commit2 = $this->getLatestCommitEventually($repositoryName); + $commitHash2 = $commit2['commitHash']; + + $first = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash1, + name: 'ci/build', + status: 'in_progress', + ); + + $second = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash2, + name: 'ci/build', + status: 'in_progress', + ); + + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('id', $second); + $this->assertNotEquals($first['id'], $second['id']); + $this->assertEquals($commitHash1, $first['head_sha']); + $this->assertEquals($commitHash2, $second['head_sha']); + $this->assertEquals('ci/build', $first['name']); + $this->assertEquals('ci/build', $second['name']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testCreateCheckRunCompleted(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-create-check-run-completed-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->getLatestCommitEventually($repositoryName); + $commitHash = $commit['commitHash']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + conclusion: 'success', + title: 'Build passed', + summary: 'All checks passed successfully.', + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertIsInt($checkRun['id']); + $this->assertEquals('ci/build', $checkRun['name']); + $this->assertEquals('completed', $checkRun['status']); + $this->assertEquals('success', $checkRun['conclusion']); + $this->assertEquals($commitHash, $checkRun['head_sha']); + $this->assertNotEmpty($checkRun['url']); + $this->assertNotEmpty($checkRun['html_url']); + $this->assertNotEmpty($checkRun['completed_at']); + $this->assertEquals('Build passed', $checkRun['output']['title']); + $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testUpdateCheckRun(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-update-check-run-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commit = $this->getLatestCommitEventually($repositoryName); + $commitHash = $commit['commitHash']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + startedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertArrayHasKey('id', $checkRun); + $this->assertEquals('in_progress', $checkRun['status']); + + $updated = $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: $checkRun['id'], + status: 'completed', + conclusion: 'neutral', + title: 'Deployment skipped', + summary: 'Deployment skipped because the branch does not match the configured branch triggers.', + completedAt: gmdate('Y-m-d\TH:i:s\Z'), + ); + + $this->assertEquals($checkRun['id'], $updated['id']); + $this->assertEquals('completed', $updated['status']); + $this->assertEquals('neutral', $updated['conclusion']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testUpdateCheckRunWithInvalidRepository(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: 'non-existing-repository-' . \uniqid(), + checkRunId: 999999999, + conclusion: 'success', + ); + } + public function testUpdateCheckRunWithInvalidId(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-update-check-run-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: 999999999, + conclusion: 'success', + ); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testUpdateCheckRunWithMissingConclusion(): void + { + $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + + $repositoryName = 'test-update-check-run-no-conclusion-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $commit = $this->getLatestCommitEventually($repositoryName); + $commitHash = $commit['commitHash']; + + $checkRun = $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + headSha: $commitHash, + name: 'ci/build', + status: 'in_progress', + ); + + $this->expectException(\Exception::class); + $this->vcsAdapter->updateCheckRun( + owner: static::$owner, + repositoryName: $repositoryName, + checkRunId: $checkRun['id'], + status: 'completed', + ); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGetEventInvalidPayload(): void { $this->expectException(Exception::class); From c2c60ae75dad002e5b457ff8e060e6ba52c90467 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:30:51 +0530 Subject: [PATCH 13/20] Resolve path sentinels the same way on every provider Moving GitLab's path sentinel tests into Base showed the contract only held for GitLab: '.' and './' returned the root listing there and nothing on Gitea, because #121 fixed the path handling in one adapter. The same call answered differently depending on the provider. normalizeRepositoryPath() moves off GitLab onto Git, and Gitea and GitHub normalize before building their contents urls, so a caller passing '.', './' or 'src//' gets the same answer everywhere. Gitea went from failing all three sentinel tests to passing them. listNamespaces moves to Base the same way check runs did - a default on Git reporting it unsupported, plus a capability - so GitLabTest is 682 -> 367 lines. --- src/VCS/Adapter/Git.php | 28 +++++++++ src/VCS/Adapter/Git/GitHub.php | 3 +- src/VCS/Adapter/Git/GitLab.php | 14 ----- src/VCS/Adapter/Git/Gitea.php | 2 + tests/VCS/Adapter/GitLabTest.php | 96 +------------------------------ tests/VCS/Base.php | 97 ++++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 110 deletions(-) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 4c650715..8769b94b 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -184,6 +184,19 @@ public function updateCheckRun( throw new Exception('updateCheckRun() is not supported by ' . $this->getName()); } + /** + * List namespaces the credentials can create repositories in. + * + * Only some providers model namespaces separately, so the default reports + * it as unsupported. + * + * @return array{items: array>, total: int} + */ + public function listNamespaces(int $page, int $per_page, string $search = ''): array + { + throw new Exception('listNamespaces() is not supported by ' . $this->getName()); + } + /** * Get commit statuses * @@ -197,6 +210,21 @@ public function updateCheckRun( */ abstract public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array; + /** + * Resolve the path sentinels a caller may pass - '', '.', './', 'src//' - + * to the plain path every provider's API expects. Providers differ on + * whether they do this themselves, so adapters normalize before calling. + */ + protected function normalizeRepositoryPath(string $path): string + { + $segments = \array_filter( + \explode('/', $path), + fn (string $segment): bool => $segment !== '' && $segment !== '.' + ); + + return \implode('/', $segments); + } + /** * Filter ref names by a shell glob pattern (e.g. 'v1.*', 'v?.0.0'). * An empty pattern returns every name unchanged. diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index d985fce8..be5cbb7b 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -482,7 +482,7 @@ public function listRepositoryLanguages(string $owner, string $repositoryName): */ public function getRepositoryContent(string $owner, string $repositoryName, string $path, string $ref = ''): array { - $url = "/repos/$owner/$repositoryName/contents/" . $path; + $url = "/repos/$owner/$repositoryName/contents/" . $this->normalizeRepositoryPath($path); if (!empty($ref)) { $url .= "?ref=$ref"; } @@ -526,6 +526,7 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri */ public function listRepositoryContents(string $owner, string $repositoryName, string $path = '', string $ref = ''): array { + $path = $this->normalizeRepositoryPath($path); $url = "/repos/$owner/$repositoryName/contents"; if (!empty($path)) { $url .= "/$path"; diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index fb4f1646..34416911 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -122,20 +122,6 @@ private function getOwnerPath(string $owner): string return $owner; } - /** - * GitLab passes path as a literal query/URL value, so unlike GitHub it - * never resolves './' or '.' to the repository root on its own. - */ - private function normalizeRepositoryPath(string $path): string - { - $segments = array_filter( - explode('/', $path), - fn (string $segment): bool => $segment !== '' && $segment !== '.' - ); - - return implode('/', $segments); - } - /** * Extract namespace ID from "id:path" format */ diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 394fd8de..8d630dad 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -423,6 +423,7 @@ public function listRepositoryLanguages(string $owner, string $repositoryName): public function getRepositoryContent(string $owner, string $repositoryName, string $path, string $ref = ''): array { + $path = $this->normalizeRepositoryPath($path); $url = "/repos/{$owner}/{$repositoryName}/contents/{$path}"; if (!empty($ref)) { $url .= "?ref=" . urlencode($ref); @@ -456,6 +457,7 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri public function listRepositoryContents(string $owner, string $repositoryName, string $path = '', string $ref = ''): array { + $path = $this->normalizeRepositoryPath($path); $url = "/repos/{$owner}/{$repositoryName}/contents"; if (!empty($path)) { $url .= "/{$path}"; diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 826b175b..e6f305ef 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -25,6 +25,7 @@ class GitLabTest extends Base protected static string $presignedTarballFragment = '/repository/archive.tar.gz?access_token='; protected static string $presignedZipballFragment = '/repository/archive.zip?access_token='; protected static string $repositoryNotFoundException = \Exception::class; + protected static bool $supportsNamespaceListing = true; protected function signWebhookPayload(string $payload, string $secret): string { @@ -358,104 +359,9 @@ public function testGetEventPullRequestDetectsExternal(): void } - public function testListRepositoryContentsRootSentinels(): void - { - $repositoryName = 'test-list-repository-contents-root-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $empty = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, ''); - $dot = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, '.'); - $dotSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, './'); - - $repeatedDotSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, './././'); - $this->assertNotEmpty($empty); - $this->assertEquals(array_column($empty, 'name'), array_column($dot, 'name')); - $this->assertEquals(array_column($empty, 'name'), array_column($dotSlash, 'name')); - $this->assertEquals(array_column($empty, 'name'), array_column($repeatedDotSlash, 'name')); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testGetRepositoryContentRootSentinelPrefix(): void - { - $repositoryName = 'test-get-repository-content-root-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $direct = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); - $prefixed = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, './README.md'); - $repeatedPrefix = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, './././README.md'); - - $this->assertEquals($direct['content'], $prefixed['content']); - $this->assertEquals($direct['content'], $repeatedPrefix['content']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testListRepositoryContentsMalformedNestedPath(): void - { - $repositoryName = 'test-list-repository-contents-malformed-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'src/main.php', 'vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src'); - $embeddedDot = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src/.'); - $doubleSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src//'); - $this->assertNotEmpty($clean); - $this->assertEquals(array_column($clean, 'name'), array_column($embeddedDot, 'name')); - $this->assertEquals(array_column($clean, 'name'), array_column($doubleSlash, 'name')); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testListNamespaces(): void - { - /** @var GitLab $adapter */ - $adapter = $this->vcsAdapter; - - $result = $adapter->listNamespaces(1, 20); - - $this->assertIsArray($result); - $this->assertArrayHasKey('items', $result); - $this->assertArrayHasKey('total', $result); - $this->assertNotEmpty($result['items']); - - $kinds = array_column($result['items'], 'kind'); - $this->assertContains('user', $kinds); - $this->assertContains('group', $kinds); - - foreach ($result['items'] as $namespace) { - $this->assertArrayHasKey('id', $namespace); - $this->assertArrayHasKey('name', $namespace); - $this->assertArrayHasKey('path', $namespace); - $this->assertArrayHasKey('kind', $namespace); - $this->assertNotEmpty($namespace['path']); - } - } - - public function testListNamespacesWithSearch(): void - { - /** @var GitLab $adapter */ - $adapter = $this->vcsAdapter; - $ownerPath = explode(':', static::$owner)[1] ?? static::$owner; - - $result = $adapter->listNamespaces(1, 20, $ownerPath); - - $this->assertNotEmpty($result['items']); - $paths = array_column($result['items'], 'path'); - $this->assertContains($ownerPath, $paths); - } } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 1229b775..ecb5414e 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -103,6 +103,8 @@ abstract class Base extends TestCase protected static bool $supportsCheckRuns = false; + protected static bool $supportsNamespaceListing = false; + /** * Whether the provider links the commit author back to an account. GitLab * reports neither, Gitea an avatar but no profile url. @@ -2045,6 +2047,101 @@ public function testUpdateCheckRunWithMissingConclusion(): void } } + public function testListNamespaces(): void + { + $this->skipUnlessSupported(static::$supportsNamespaceListing, 'listing namespaces'); + + $result = $this->vcsAdapter->listNamespaces(1, 20); + + $this->assertIsArray($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('total', $result); + $this->assertNotEmpty($result['items']); + + $kinds = array_column($result['items'], 'kind'); + $this->assertContains('user', $kinds); + $this->assertContains('group', $kinds); + + foreach ($result['items'] as $namespace) { + $this->assertArrayHasKey('id', $namespace); + $this->assertArrayHasKey('name', $namespace); + $this->assertArrayHasKey('path', $namespace); + $this->assertArrayHasKey('kind', $namespace); + $this->assertNotEmpty($namespace['path']); + } + } + public function testListNamespacesWithSearch(): void + { + $this->skipUnlessSupported(static::$supportsNamespaceListing, 'listing namespaces'); + + $ownerPath = $this->ownerPath(); + + $result = $this->vcsAdapter->listNamespaces(1, 20, $ownerPath); + + $this->assertNotEmpty($result['items']); + $paths = array_column($result['items'], 'path'); + $this->assertContains($ownerPath, $paths); + } + public function testListRepositoryContentsRootSentinels(): void + { + $repositoryName = 'test-list-repository-contents-root-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $empty = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, ''); + $dot = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, '.'); + $dotSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, './'); + + $repeatedDotSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, './././'); + + $this->assertNotEmpty($empty); + $this->assertEquals(array_column($empty, 'name'), array_column($dot, 'name')); + $this->assertEquals(array_column($empty, 'name'), array_column($dotSlash, 'name')); + $this->assertEquals(array_column($empty, 'name'), array_column($repeatedDotSlash, 'name')); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGetRepositoryContentRootSentinelPrefix(): void + { + $repositoryName = 'test-get-repository-content-root-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $direct = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); + $prefixed = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, './README.md'); + $repeatedPrefix = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, './././README.md'); + + $this->assertEquals($direct['content'], $prefixed['content']); + $this->assertEquals($direct['content'], $repeatedPrefix['content']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testListRepositoryContentsMalformedNestedPath(): void + { + $repositoryName = 'test-list-repository-contents-malformed-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'src/main.php', 'vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src'); + $embeddedDot = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src/.'); + $doubleSlash = $this->vcsAdapter->listRepositoryContents(static::$owner, $repositoryName, 'src//'); + + $this->assertNotEmpty($clean); + $this->assertEquals(array_column($clean, 'name'), array_column($embeddedDot, 'name')); + $this->assertEquals(array_column($clean, 'name'), array_column($doubleSlash, 'name')); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGetEventInvalidPayload(): void { $this->expectException(Exception::class); From 69d87918f660bda3f58272e33b11ee92837ae885 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:32:56 +0530 Subject: [PATCH 14/20] Report a missing GitHub pull request instead of returning the error body Sharing testGetPullRequestWithInvalidNumber ran it against GitHub for the first time, where it had been skipped, and GitHub returned the 404 body as if it were a pull request while Gitea and GitLab both throw. Same shape as createRepository not checking its status code. Also waits for both branches to be listable before paging through them, so GitHub not having indexed the second one yet stops failing the pagination test. --- src/VCS/Adapter/Git/GitHub.php | 6 ++++++ tests/VCS/Adapter/GitHubTest.php | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index be5cbb7b..6f052061 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -737,6 +737,12 @@ public function getPullRequest(string $owner, string $repositoryName, int $pullR $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"]); + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get pull request: HTTP {$statusCode}", $statusCode); + } + return $response['body'] ?? []; } diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 5b641909..4b37828b 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -226,6 +226,11 @@ public function testListBranchesPagination(): void /** @var GitHub $adapter */ $adapter = $this->vcsAdapter; + // Both branches have to be listable before paging through them + $this->assertEventually(function () use ($adapter, $repositoryName) { + $this->assertCount(3, $adapter->listBranches(static::$owner, $repositoryName, 100, 1)); + }); + $page1 = $adapter->listBranches(static::$owner, $repositoryName, 1, 1); $this->assertSame(['branch-a'], $page1); From 282168b60745f9eec04cb3977c015d6f429e23e0 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:39:06 +0530 Subject: [PATCH 15/20] Share the last four tests GitHub was keeping to itself None of them were really GitHub specific: - getOwnerName differed only in which argument the provider reads, so the shared test passes both an installation id and a repository id and each adapter uses the one it resolves owners from - language stats differ in when they appear, not what they are, so the wait and the inconclusive skip are behind computesLanguagesAsynchronously - case sensitive paths and the git blob SHA are git behaviours: Gitea and GitLab both hold to them, confirmed by running it GitHubTest 738 -> 263 lines. What is left is the hand-built webhook payloads, its own listBranches signature, and the provider facts. --- tests/VCS/Adapter/GitHubTest.php | 72 +------------------------------- tests/VCS/Base.php | 66 ++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 76 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 4b37828b..f4605416 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -7,12 +7,10 @@ use Utopia\System\System; use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git\GitHub; -use Utopia\VCS\Exception\FileNotFound; class GitHubTest extends Base { protected static string $owner = ''; - protected static string $installationId = ''; protected static string $defaultBranch = 'main'; /** @var array */ protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; @@ -25,7 +23,7 @@ class GitHubTest extends Base protected static bool $supportsCommitStatusLookup = false; protected static bool $supportsTags = false; protected static bool $supportsUserLookup = false; - protected static bool $supportsRepositoryLanguages = false; + protected static bool $computesLanguagesAsynchronously = true; protected static bool $supportsWebhookDelivery = false; protected static bool $resolvesOwnerFromRepositoryId = false; protected static bool $rejectsInvalidRepositoryNames = false; @@ -179,38 +177,7 @@ public function testGetEventInstallation(): void } - public function testGetRepositoryContentSha(): void - { - $repositoryName = 'test-get-repository-content-sha-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $result = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); - - // GitHub reports the git blob SHA, so it has to match what git would compute - $expectedSha = \hash('sha1', 'blob ' . $result['size'] . "\0" . $result['content']); - $this->assertSame($expectedSha, $result['sha']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testGetRepositoryContentCaseSensitive(): void - { - $repositoryName = 'test-get-repository-content-case-' . \uniqid(); - try { - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - - $this->expectException(FileNotFound::class); - $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'readme.md'); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } public function testListBranchesPagination(): void { @@ -266,14 +233,6 @@ public function testListBranchesPagination(): void - public function testGetOwnerName(): void - { - $result = $this->vcsAdapter->getOwnerName(static::$installationId); - - $this->assertIsString($result); - $this->assertNotEmpty($result); - $this->assertSame(static::$owner, $result); - } @@ -298,35 +257,6 @@ public function testGetOwnerName(): void - public function testListRepositoryLanguages(): void - { - $repositoryName = 'test-list-repository-languages-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'main.php', 'vcsAdapter->createFile(static::$owner, $repositoryName, 'script.js', 'console.log("test");'); - - // Unlike the self-hosted providers, GitHub computes language stats out of - // band with no guaranteed turnaround, and reports none at all until that - // finishes. Waiting it out is the best we can do; a repository that still - // has no stats says nothing about the adapter, so report that as - // inconclusive instead of failing the suite. - $languages = []; - try { - $this->assertEventually(function () use (&$languages, $repositoryName) { - $languages = $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName); - $this->assertNotEmpty($languages); - }, 60000, 5000); - } catch (\Throwable $e) { - $this->markTestSkipped('GitHub has not computed language stats for the new repository yet'); - } - - $this->assertContains('PHP', $languages); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index ecb5414e..67afae63 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -21,6 +21,12 @@ abstract class Base extends TestCase */ protected static string $existingUser = 'root'; + /** + * Installation the credentials belong to, for providers that resolve an + * owner from it rather than from a repository. + */ + protected static string $installationId = ''; + /** * Field the provider reports a user's handle under. */ @@ -105,6 +111,13 @@ abstract class Base extends TestCase protected static bool $supportsNamespaceListing = false; + /** + * Whether the provider computes language stats out of band. GitHub does, + * with no guaranteed turnaround, so a repository that still has none says + * nothing about the adapter. + */ + protected static bool $computesLanguagesAsynchronously = false; + /** * Whether the provider links the commit author back to an account. GitLab * reports neither, Gitea an avatar but no profile url. @@ -630,10 +643,18 @@ public function testListRepositoryLanguages(): void $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'script.js', 'console.log("test");'); $languages = []; - $this->assertEventually(function () use (&$languages, $repositoryName) { - $languages = $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName); - $this->assertNotEmpty($languages); - }, 30000, 2000); + try { + $this->assertEventually(function () use (&$languages, $repositoryName) { + $languages = $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName); + $this->assertNotEmpty($languages); + }, static::$computesLanguagesAsynchronously ? 60000 : 30000, static::$computesLanguagesAsynchronously ? 5000 : 2000); + } catch (\Throwable $e) { + if (!static::$computesLanguagesAsynchronously) { + throw $e; + } + + $this->markTestSkipped('The provider has not computed language stats for the new repository yet'); + } $this->assertIsArray($languages); $this->assertContains('PHP', $languages); @@ -1020,7 +1041,9 @@ public function testGetOwnerName(): void $this->assertIsNumeric($created['id']); $repositoryId = (int) $created['id']; - $this->assertSame($this->ownerPath(), $this->vcsAdapter->getOwnerName('', $repositoryId)); + // GitHub resolves the owner from the installation, the others from the + // repository, so pass both and let each use what it reads + $this->assertSame($this->ownerPath(), $this->vcsAdapter->getOwnerName(static::$installationId, $repositoryId)); } finally { $this->discardRepositories($repositoryName); } @@ -2142,6 +2165,39 @@ public function testListRepositoryContentsMalformedNestedPath(): void } } + public function testGetRepositoryContentIsCaseSensitive(): void + { + $repositoryName = 'test-get-repository-content-case-' . \uniqid(); + + try { + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $this->expectException(FileNotFound::class); + $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'readme.md'); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testGetRepositoryContentReportsBlobSha(): void + { + $repositoryName = 'test-get-repository-content-sha-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + $result = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); + + // Every provider here is git backed, so the sha is the blob hash + $expected = \hash('sha1', 'blob ' . $result['size'] . "\0" . $result['content']); + $this->assertSame($expected, $result['sha']); + } finally { + $this->discardRepositories($repositoryName); + } + } + public function testGetEventInvalidPayload(): void { $this->expectException(Exception::class); From 01ee240c5bd85e10a568163b88b564ac051b6b96 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:50:03 +0530 Subject: [PATCH 16/20] Share the webhook payload tests through per-provider builders Every adapter's getEvent already reports the same normalized keys - branch, branchCreated, branchDeleted, repositoryId, repositoryName, owner, commitHash, headCommit*, affectedFiles, external - so the only thing that was provider specific about these tests was the payload going in. Base now owns the assertions and declares two builders each adapter fills in from the same EVENT_* facts: a push payload and a pull request payload. GitHub signals a created branch with a flag, GitLab with an all-zero sha, and each shapes the owner and the pull request number its own way, which is exactly what the builders express. That covers push, pull request, branch created, branch deleted and external pull requests for every provider from one place. GitLab keeps the two that are its own semantics - matching checkout_sha and its action mapping - and GitHub keeps installation events. GitHubTest 738 -> 244 lines, GitLabTest 682 -> 265, GiteaTest 634 -> 146, GogsTest 131 -> 74. --- tests/VCS/Adapter/GitHubTest.php | 111 ++++++-------- tests/VCS/Adapter/GitLabTest.php | 210 +++++++------------------- tests/VCS/Adapter/GiteaTest.php | 243 ++++++------------------------- tests/VCS/Adapter/GogsTest.php | 10 +- tests/VCS/Base.php | 151 ++++++++++++++++++- 5 files changed, 293 insertions(+), 432 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index f4605416..4873c2af 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -18,6 +18,7 @@ class GitHubTest extends Base protected static bool $supportsInstallationRepository = true; protected static bool $supportsCheckRuns = true; protected static bool $reportsCommitAuthorAvatar = true; + protected static string $avatarDomain = 'githubusercontent.com'; protected static bool $reportsCommitAuthorUrl = true; protected static bool $supportsPullRequestCreation = false; protected static bool $supportsCommitStatusLookup = false; @@ -62,98 +63,78 @@ protected function setupAdapter(): void } - public function testGetEventPush(): void + + + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string { - $payload = json_encode([ - 'created' => false, - 'deleted' => false, - 'ref' => 'refs/heads/main', + return (string) json_encode([ + 'created' => $created, + 'deleted' => $deleted, + 'ref' => 'refs/heads/' . $branch, 'before' => 'abc123', - 'after' => 'def456', + 'after' => self::EVENT_COMMIT_HASH, 'repository' => [ - 'id' => 603754812, - 'name' => 'testing-fork', - 'full_name' => 'vermakhushboo/testing-fork', + 'id' => (int) self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'full_name' => self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, 'private' => true, - 'html_url' => 'https://github.com/vermakhushboo/testing-fork', - 'owner' => ['name' => 'vermakhushboo'], + 'html_url' => 'https://github.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, + 'owner' => ['name' => self::EVENT_OWNER, 'login' => self::EVENT_OWNER], ], 'installation' => ['id' => 1234], 'head_commit' => [ - 'author' => ['name' => 'Khushboo Verma'], - 'message' => 'Update index.js', - 'url' => 'https://github.com/vermakhushboo/testing-fork/commit/def456', - ], - 'commits' => [ - [ - 'id' => 'def456', - 'added' => ['src/lib.js'], - 'removed' => ['README.md'], - 'modified' => ['src/main.js'], - ], + 'id' => self::EVENT_COMMIT_HASH, + 'message' => self::EVENT_COMMIT_MESSAGE, + 'url' => 'https://github.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME . '/commit/' . self::EVENT_COMMIT_HASH, + 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], ], + 'commits' => [[ + 'id' => self::EVENT_COMMIT_HASH, + 'added' => $added, + 'removed' => $removed, + 'modified' => $modified, + ]], 'sender' => [ - 'html_url' => 'https://github.com/vermakhushboo', - 'avatar_url' => 'https://avatars.githubusercontent.com/u/43381712?v=4', + 'html_url' => 'https://github.com/' . self::EVENT_AUTHOR_NAME, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/1?v=4', ], ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('push', $payload); - - $this->assertSame('main', $result['branch']); - $this->assertSame('603754812', $result['repositoryId']); - $this->assertCount(3, $result['affectedFiles']); - $this->assertSame('src/lib.js', $result['affectedFiles'][0]); - $this->assertSame('README.md', $result['affectedFiles'][1]); - $this->assertSame('src/main.js', $result['affectedFiles'][2]); } - public function testGetEventPullRequest(): void + protected function pullRequestPayload(bool $external = false): string { - $payload = json_encode([ + $headOwner = $external ? 'someone-else' : self::EVENT_OWNER; + + return (string) json_encode([ 'action' => 'opened', - 'number' => 1, + 'number' => self::EVENT_PULL_REQUEST_NUMBER, 'pull_request' => [ 'id' => 1303283688, 'state' => 'open', - 'html_url' => 'https://github.com/vermakhushboo/g4-node-function/pull/17', + 'html_url' => 'https://github.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME . '/pull/' . self::EVENT_PULL_REQUEST_NUMBER, 'head' => [ - 'ref' => 'test', - 'sha' => 'a27dbe54b17032ee35a16c24bac151e5c2b33328', - 'label' => 'vermakhushboo:test', - 'user' => ['login' => 'vermakhushboo'], + 'ref' => self::EVENT_HEAD_BRANCH, + 'sha' => self::EVENT_COMMIT_HASH, + 'label' => $headOwner . ':' . self::EVENT_HEAD_BRANCH, + 'user' => ['login' => $headOwner], ], 'base' => [ - 'label' => 'vermakhushboo:main', - 'user' => ['login' => 'vermakhushboo'], - ], - 'user' => [ - 'login' => 'vermakhushboo', - 'avatar_url' => 'https://avatars.githubusercontent.com/u/43381712?v=4', + 'ref' => static::$defaultBranch, + 'label' => self::EVENT_OWNER . ':' . static::$defaultBranch, + 'user' => ['login' => self::EVENT_OWNER], ], + 'user' => ['login' => $headOwner, 'avatar_url' => 'https://avatars.githubusercontent.com/u/1?v=4'], ], 'repository' => [ - 'id' => 3498, - 'name' => 'functions-example', - 'owner' => ['login' => 'vermakhushboo'], - 'html_url' => 'https://github.com/vermakhushboo/g4-node-function', + 'id' => (int) self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'full_name' => self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, + 'owner' => ['login' => self::EVENT_OWNER, 'name' => self::EVENT_OWNER], + 'html_url' => 'https://github.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, ], 'installation' => ['id' => 9876], - 'sender' => ['html_url' => 'https://github.com/vermakhushboo'], + 'sender' => ['html_url' => 'https://github.com/' . $headOwner], ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('pull_request', $payload); - - $this->assertSame('opened', $result['action']); - $this->assertSame(1, $result['pullRequestNumber']); } public function testGetEventInstallation(): void diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index e6f305ef..a1f532b5 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -26,6 +26,7 @@ class GitLabTest extends Base protected static string $presignedZipballFragment = '/repository/archive.zip?access_token='; protected static string $repositoryNotFoundException = \Exception::class; protected static bool $supportsNamespaceListing = true; + protected static bool $deletesRepositoriesSynchronously = false; protected function signWebhookPayload(string $payload, string $secret): string { @@ -135,99 +136,7 @@ protected function setupGitLab(): void - public function testGetEventPush(): void - { - $payload = json_encode([ - 'object_kind' => 'push', - 'ref' => 'refs/heads/main', - 'before' => 'before123', - 'after' => 'abc123', - 'checkout_sha' => 'abc123', - 'user_avatar' => 'http://example.com/avatar.png', - 'project' => [ - 'id' => 123, - 'name' => 'test-repo', - 'namespace' => 'test-org', - 'web_url' => 'http://example.com/test-org/test-repo', - ], - 'commits' => [ - [ - 'id' => 'abc123', - 'message' => 'Test commit', - 'url' => 'http://example.com/commit/abc123', - 'author' => ['name' => 'Test User', 'email' => 'test@example.com'], - 'added' => ['file1.txt'], - 'modified' => [], - 'removed' => [], - ], - ], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('Push Hook', $payload); - - $this->assertIsArray($result); - $this->assertFalse($result['branchDeleted']); - $this->assertSame('main', $result['branch']); - $this->assertSame('http://example.com/test-org/test-repo/-/tree/main', $result['branchUrl']); - $this->assertSame('123', $result['repositoryId']); - $this->assertSame('test-repo', $result['repositoryName']); - $this->assertSame('http://example.com/test-org/test-repo', $result['repositoryUrl']); - $this->assertSame('test-org', $result['owner']); - $this->assertSame('abc123', $result['commitHash']); - $this->assertSame('Test User', $result['headCommitAuthorName']); - $this->assertSame('test@example.com', $result['headCommitAuthorEmail']); - $this->assertSame('Test commit', $result['headCommitMessage']); - $this->assertSame('http://example.com/commit/abc123', $result['headCommitUrl']); - $this->assertSame(['file1.txt'], $result['affectedFiles']); - } - public function testGetEventPullRequest(): void - { - $payload = json_encode([ - 'object_kind' => 'merge_request', - 'project' => [ - 'id' => 123, - 'name' => 'test-repo', - 'namespace' => 'test-org', - 'web_url' => 'http://example.com/test-org/test-repo', - ], - 'object_attributes' => [ - 'iid' => 1, - 'title' => 'Test MR', - 'action' => 'open', - 'source_branch' => 'feature', - 'target_branch' => 'main', - 'source_project_id' => 123, - 'target_project_id' => 123, - 'url' => 'http://example.com/mr/1', - 'last_commit' => [ - 'id' => 'abc123', - 'message' => 'Test commit', - 'url' => 'http://example.com/commit/abc123', - 'author' => ['name' => 'Test User'], - ], - ], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('Merge Request Hook', $payload); - - $this->assertIsArray($result); - $this->assertSame('feature', $result['branch']); - $this->assertSame('opened', $result['action']); - $this->assertFalse($result['external']); - $this->assertSame(1, $result['pullRequestNumber']); - $this->assertSame('123', $result['repositoryId']); - $this->assertSame('test-repo', $result['repositoryName']); - $this->assertSame('abc123', $result['commitHash']); - } @@ -273,49 +182,7 @@ public function testGetEventPushMatchesCheckoutSha(): void - public function testGetEventPushDetectsBranchCreated(): void - { - $allZeroSha = str_repeat('0', 40); - $payload = json_encode([ - 'object_kind' => 'push', - 'ref' => 'refs/heads/main', - 'before' => $allZeroSha, - 'after' => 'abc123', - 'checkout_sha' => 'abc123', - 'project' => ['id' => 123, 'name' => 'test-repo', 'namespace' => 'test-org', 'web_url' => 'http://example.com/test-org/test-repo'], - 'commits' => [], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - $result = $this->vcsAdapter->getEvent('Push Hook', $payload); - $this->assertTrue($result['branchCreated']); - $this->assertFalse($result['branchDeleted']); - } - - public function testGetEventPushDetectsBranchDeleted(): void - { - $allZeroSha = str_repeat('0', 40); - $payload = json_encode([ - 'object_kind' => 'push', - 'ref' => 'refs/heads/main', - 'before' => 'abc123', - 'after' => $allZeroSha, - 'checkout_sha' => '', - 'project' => ['id' => 123, 'name' => 'test-repo', 'namespace' => 'test-org', 'web_url' => 'http://example.com/test-org/test-repo'], - 'commits' => [], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('Push Hook', $payload); - $this->assertFalse($result['branchCreated']); - $this->assertTrue($result['branchDeleted']); - } public function testGetEventPullRequestActionMapping(): void { @@ -335,33 +202,64 @@ public function testGetEventPullRequestActionMapping(): void } } - public function testGetEventPullRequestDetectsExternal(): void + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string { - $payload = json_encode([ + $blank = str_repeat('0', 40); + $repositoryUrl = 'http://example.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; + + return (string) json_encode([ + 'object_kind' => 'push', + 'ref' => 'refs/heads/' . $branch, + // GitLab signals a created or deleted branch with an all-zero sha + 'before' => $created ? $blank : 'abc123', + 'after' => $deleted ? $blank : self::EVENT_COMMIT_HASH, + 'checkout_sha' => $deleted ? '' : self::EVENT_COMMIT_HASH, + 'user_avatar' => 'http://example.com/avatar.png', + 'project' => [ + 'id' => (int) self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'namespace' => self::EVENT_OWNER, + 'web_url' => $repositoryUrl, + ], + 'commits' => $deleted ? [] : [[ + 'id' => self::EVENT_COMMIT_HASH, + 'message' => self::EVENT_COMMIT_MESSAGE, + 'url' => $repositoryUrl . '/-/commit/' . self::EVENT_COMMIT_HASH, + 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], + 'added' => $added, + 'removed' => $removed, + 'modified' => $modified, + ]], + ]); + } + + protected function pullRequestPayload(bool $external = false): string + { + return (string) json_encode([ 'object_kind' => 'merge_request', - 'project' => ['id' => 1, 'name' => 'r', 'namespace' => 'o', 'web_url' => 'http://example.com/o/r'], + 'project' => [ + 'id' => (int) self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'namespace' => self::EVENT_OWNER, + 'web_url' => 'http://example.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, + ], 'object_attributes' => [ - 'iid' => 1, + 'iid' => self::EVENT_PULL_REQUEST_NUMBER, + 'title' => 'Test MR', + // GitLab calls it 'open' and normalizes to 'opened' 'action' => 'open', - 'source_branch' => 'f', - 'target_branch' => 'main', - 'source_project_id' => 456, - 'target_project_id' => 123, + 'source_branch' => self::EVENT_HEAD_BRANCH, + 'target_branch' => static::$defaultBranch, + 'source_project_id' => $external ? 456 : (int) self::EVENT_REPOSITORY_ID, + 'target_project_id' => (int) self::EVENT_REPOSITORY_ID, + 'url' => 'http://example.com/mr/' . self::EVENT_PULL_REQUEST_NUMBER, + 'last_commit' => [ + 'id' => self::EVENT_COMMIT_HASH, + 'message' => self::EVENT_COMMIT_MESSAGE, + 'url' => 'http://example.com/commit/' . self::EVENT_COMMIT_HASH, + 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], + ], ], ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('Merge Request Hook', $payload); - $this->assertTrue($result['external']); } - - - - - - - } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index d36bc222..99a91cc3 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -7,7 +7,6 @@ use Utopia\System\System; use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git\Gitea; -use Utopia\VCS\Exception\RepositoryNotFound; class GiteaTest extends Base { @@ -69,233 +68,79 @@ protected function setupGitea(): void } } - - public function testGetRepositoryAfterDeleteFails(): void - { - $repositoryName = 'test-get-deleted-repository-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - - $this->expectException(RepositoryNotFound::class); - $this->vcsAdapter->getRepository(static::$owner, $repositoryName); - } - - - - public function testGetCommitAuthorAvatar(): void + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string { - $repositoryName = 'test-get-commit-avatar-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + $repositoryUrl = 'http://gitea:3000/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; - $commit = $this->vcsAdapter->getCommit(static::$owner, $repositoryName, $commitHash); - - $this->assertNotEmpty($commit['commitAuthorAvatar']); - $this->assertStringContainsString(static::$avatarDomain, $commit['commitAuthorAvatar']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - - - - - public function testGetEventPush(): void - { - $payload = json_encode([ - 'ref' => 'refs/heads/' . static::$defaultBranch, + return (string) json_encode([ + 'ref' => 'refs/heads/' . $branch, 'before' => 'abc123', - 'after' => 'def456', - 'created' => false, - 'deleted' => false, + 'after' => self::EVENT_COMMIT_HASH, + 'created' => $created, + 'deleted' => $deleted, 'repository' => [ - 'id' => 123, - 'name' => 'test-repo', - 'html_url' => 'http://gitea:3000/test-owner/test-repo', - 'owner' => [ - 'login' => 'test-owner', - ], + 'id' => (int) self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'full_name' => self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, + 'html_url' => $repositoryUrl, + 'owner' => ['login' => self::EVENT_OWNER], ], 'sender' => [ - 'login' => 'pusher-user', + 'login' => self::EVENT_AUTHOR_NAME, 'html_url' => 'http://gitea:3000/pusher-user', 'avatar_url' => 'http://gitea:3000/avatars/pusher', ], 'head_commit' => [ - 'id' => 'def456', - 'message' => 'Test commit message', - 'url' => 'http://gitea:3000/test-owner/test-repo/commit/def456', - 'author' => [ - 'name' => 'Test Author', - 'email' => 'author@example.com', - ], - ], - 'commits' => [ - [ - 'id' => 'def456', - 'added' => ['file1.txt'], - 'removed' => ['file2.txt'], - 'modified' => ['file3.txt'], - ], + 'id' => self::EVENT_COMMIT_HASH, + 'message' => self::EVENT_COMMIT_MESSAGE, + 'url' => $repositoryUrl . '/commit/' . self::EVENT_COMMIT_HASH, + 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], ], + 'commits' => [[ + 'id' => self::EVENT_COMMIT_HASH, + 'added' => $added, + 'removed' => $removed, + 'modified' => $modified, + ]], ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('push', $payload); - - $this->assertIsArray($result); - $this->assertArrayHasKey('branch', $result); - $this->assertArrayHasKey('commitHash', $result); - $this->assertArrayHasKey('repositoryName', $result); - $this->assertArrayHasKey('owner', $result); - $this->assertArrayHasKey('affectedFiles', $result); - - $this->assertSame(static::$defaultBranch, $result['branch']); - $this->assertSame('def456', $result['commitHash']); - $this->assertSame('test-repo', $result['repositoryName']); - $this->assertSame('test-owner', $result['owner']); - $this->assertSame('Test commit message', $result['headCommitMessage']); - $this->assertSame('Test Author', $result['headCommitAuthorName']); - $this->assertSame('author@example.com', $result['headCommitAuthorEmail']); - - $this->assertIsArray($result['affectedFiles']); - $this->assertContains('file1.txt', $result['affectedFiles']); - $this->assertContains('file2.txt', $result['affectedFiles']); - $this->assertContains('file3.txt', $result['affectedFiles']); } - public function testGetEventPullRequest(): void + protected function pullRequestPayload(bool $external = false): string { - $payload = json_encode([ + $repositoryUrl = 'http://gitea:3000/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; + $headRepository = $external + ? 'someone-else/forked-repo' + : self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; + + return (string) json_encode([ 'action' => 'opened', - 'number' => 42, + 'number' => self::EVENT_PULL_REQUEST_NUMBER, 'pull_request' => [ 'id' => 1, - 'number' => 42, + 'number' => self::EVENT_PULL_REQUEST_NUMBER, 'state' => 'open', 'title' => 'Test PR', 'head' => [ - 'ref' => 'feature-branch', - 'sha' => 'abc123', - 'repo' => [ - 'full_name' => 'test-owner/test-repo', - ], - 'user' => [ - 'login' => 'pr-author', - ], + 'ref' => self::EVENT_HEAD_BRANCH, + 'sha' => self::EVENT_COMMIT_HASH, + 'repo' => ['full_name' => $headRepository], + 'user' => ['login' => self::EVENT_OWNER], ], 'base' => [ 'ref' => static::$defaultBranch, - 'sha' => 'def456', - 'user' => [ - 'login' => 'base-owner', - ], - ], - 'user' => [ - 'login' => 'pr-author', - 'avatar_url' => 'http://gitea:3000/avatars/pr-author', - ], - ], - 'repository' => [ - 'id' => 123, - 'name' => 'test-repo', - 'full_name' => 'test-owner/test-repo', - 'html_url' => 'http://gitea:3000/test-owner/test-repo', - 'owner' => [ - 'login' => 'test-owner', - ], - ], - 'sender' => [ - 'login' => 'sender-user', - 'html_url' => 'http://gitea:3000/sender-user', - ], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('pull_request', $payload); - - $this->assertIsArray($result); - $this->assertArrayHasKey('branch', $result); - $this->assertArrayHasKey('pullRequestNumber', $result); - $this->assertArrayHasKey('action', $result); - $this->assertArrayHasKey('commitHash', $result); - $this->assertArrayHasKey('external', $result); - - $this->assertSame('feature-branch', $result['branch']); - $this->assertSame(42, $result['pullRequestNumber']); - $this->assertSame('opened', $result['action']); - $this->assertSame('abc123', $result['commitHash']); - $this->assertSame('test-repo', $result['repositoryName']); - $this->assertSame('test-owner', $result['owner']); - $this->assertFalse($result['external']); - } - - public function testGetEventPullRequestExternal(): void - { - $payload = json_encode([ - 'action' => 'opened', - 'number' => 42, - 'pull_request' => [ - 'head' => [ - 'ref' => 'feature-branch', 'sha' => 'abc123', - 'repo' => [ - 'full_name' => 'external-user/forked-repo', - ], - ], - 'base' => [ - 'ref' => static::$defaultBranch, - ], - 'user' => [ - 'avatar_url' => 'http://gitea:3000/avatars/external', + 'user' => ['login' => self::EVENT_OWNER], ], + 'user' => ['login' => self::EVENT_OWNER, 'avatar_url' => 'http://gitea:3000/avatars/pr-author'], ], 'repository' => [ - 'id' => 123, - 'name' => 'test-repo', - 'full_name' => 'test-owner/test-repo', - 'html_url' => 'http://gitea:3000/test-owner/test-repo', - 'owner' => [ - 'login' => 'test-owner', - ], - ], - 'sender' => [ - 'html_url' => 'http://gitea:3000/external-user', + 'id' => (int) self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'full_name' => self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, + 'html_url' => $repositoryUrl, + 'owner' => ['login' => self::EVENT_OWNER], ], + 'sender' => ['login' => self::EVENT_OWNER, 'html_url' => 'http://gitea:3000/' . self::EVENT_OWNER], ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('pull_request', $payload); - - $this->assertTrue($result['external']); } - - - - - - - - - - - - - - - - } diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index 7ac151f8..19623b0c 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -18,6 +18,7 @@ class GogsTest extends GiteaTest protected static bool $supportsCommitStatuses = false; protected static bool $supportsCommitStatusLookup = false; protected static bool $supportsRepositoryLanguages = false; + protected static bool $createsEmptyRepositories = false; protected static string $eventHeader = 'x-gogs-event'; protected static string $signatureHeader = 'x-gogs-signature'; @@ -70,13 +71,4 @@ protected function setupGogs(): void // Repository languages - public function testListBranchesEmptyRepository(): void - { - // The Gogs adapter creates repositories with `auto_init: true` (plus a - // default README), so a default branch always exists on creation — - // an empty repository is not reachable through this adapter. This - // also avoids Gogs' HTTP 500 response from `/branches` on commit-less - // repos. - $this->markTestSkipped('Gogs adapter creates repositories with auto_init, so a default branch always exists'); - } } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 67afae63..63dd0bb1 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -12,6 +12,24 @@ abstract class Base extends TestCase { + protected const EVENT_REPOSITORY_ID = '123'; + + protected const EVENT_REPOSITORY_NAME = 'test-repo'; + + protected const EVENT_OWNER = 'test-owner'; + + protected const EVENT_COMMIT_HASH = 'def456'; + + protected const EVENT_COMMIT_MESSAGE = 'Test commit message'; + + protected const EVENT_AUTHOR_NAME = 'Test Author'; + + protected const EVENT_AUTHOR_EMAIL = 'author@example.com'; + + protected const EVENT_HEAD_BRANCH = 'feature-branch'; + + protected const EVENT_PULL_REQUEST_NUMBER = 42; + protected Git $vcsAdapter; protected static string $owner = ''; protected static string $defaultBranch = 'main'; @@ -118,6 +136,23 @@ abstract class Base extends TestCase */ protected static bool $computesLanguagesAsynchronously = false; + /** + * Host the provider serves commit author avatars from. + */ + protected static string $avatarDomain = ''; + + /** + * Whether a repository is gone as soon as delete returns. GitLab schedules + * it instead. + */ + protected static bool $deletesRepositoriesSynchronously = true; + + /** + * Whether a new repository starts with no commits. The Gogs adapter creates + * one with an initial commit, so it never has an empty repository. + */ + protected static bool $createsEmptyRepositories = true; + /** * Whether the provider links the commit author back to an account. GitLab * reports neither, Gitea an avatar but no profile url. @@ -144,11 +179,20 @@ abstract protected function setupAdapter(): void; abstract protected function signWebhookPayload(string $payload, string $secret): string; /** - * Webhook payloads are provider specific. + * Build a push payload shaped the way this provider sends one, carrying the + * EVENT_* facts above. + * + * @param array $added + * @param array $removed + * @param array $modified */ - abstract public function testGetEventPush(): void; + abstract protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string; - abstract public function testGetEventPullRequest(): void; + /** + * Build a pull request payload shaped the way this provider sends one, + * opening EVENT_HEAD_BRANCH against the default branch. + */ + abstract protected function pullRequestPayload(bool $external = false): string; protected function setUp(): void { @@ -707,6 +751,8 @@ public function testListBranches(): void public function testListBranchesEmptyRepository(): void { + $this->skipUnlessSupported(static::$createsEmptyRepositories, 'repositories without an initial commit'); + $repositoryName = 'test-list-branches-empty-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1715,6 +1761,8 @@ public function testSearchRepositoriesPagination(): void public function testListTagsCommitlessRepository(): void { + $this->skipUnlessSupported(static::$createsEmptyRepositories, 'repositories without an initial commit'); + $repositoryName = 'test-list-tags-commitless-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -2198,6 +2246,103 @@ public function testGetRepositoryContentReportsBlobSha(): void } } + public function testGetCommitAuthorAvatar(): void + { + $this->skipUnlessSupported(static::$reportsCommitAuthorAvatar, 'commit author avatars'); + + $repositoryName = 'test-get-commit-avatar-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $commit = $this->vcsAdapter->getCommit(static::$owner, $repositoryName, $commitHash); + + $this->assertNotEmpty($commit['commitAuthorAvatar']); + $this->assertStringContainsString(static::$avatarDomain, $commit['commitAuthorAvatar']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + public function testGetRepositoryAfterDeleteFails(): void + { + $this->skipUnlessSupported(static::$deletesRepositoriesSynchronously, 'deleting a repository straight away'); + + $repositoryName = 'test-get-deleted-repository-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + + $this->expectException(RepositoryNotFound::class); + $this->vcsAdapter->getRepository(static::$owner, $repositoryName); + } + + public function testGetEventPush(): void + { + $result = $this->vcsAdapter->getEvent( + static::$pushEventName, + $this->pushPayload(static::$defaultBranch, ['file1.txt'], ['file2.txt'], ['file3.txt']) + ); + + $this->assertSame(static::$defaultBranch, $result['branch']); + $this->assertSame(self::EVENT_REPOSITORY_ID, $result['repositoryId']); + $this->assertSame(self::EVENT_REPOSITORY_NAME, $result['repositoryName']); + $this->assertSame(self::EVENT_OWNER, $result['owner']); + $this->assertSame(self::EVENT_COMMIT_HASH, $result['commitHash']); + $this->assertSame(self::EVENT_COMMIT_MESSAGE, $result['headCommitMessage']); + $this->assertSame(self::EVENT_AUTHOR_NAME, $result['headCommitAuthorName']); + $this->assertSame(self::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); + $this->assertNotEmpty($result['headCommitUrl']); + $this->assertNotEmpty($result['repositoryUrl']); + $this->assertNotEmpty($result['branchUrl']); + $this->assertFalse($result['branchCreated']); + $this->assertFalse($result['branchDeleted']); + $this->assertEqualsCanonicalizing(['file1.txt', 'file2.txt', 'file3.txt'], $result['affectedFiles']); + } + + public function testGetEventPushDetectsBranchCreated(): void + { + $result = $this->vcsAdapter->getEvent( + static::$pushEventName, + $this->pushPayload(static::$defaultBranch, created: true) + ); + + $this->assertTrue($result['branchCreated']); + $this->assertFalse($result['branchDeleted']); + } + + public function testGetEventPushDetectsBranchDeleted(): void + { + $result = $this->vcsAdapter->getEvent( + static::$pushEventName, + $this->pushPayload(static::$defaultBranch, deleted: true) + ); + + $this->assertFalse($result['branchCreated']); + $this->assertTrue($result['branchDeleted']); + } + + public function testGetEventPullRequest(): void + { + $result = $this->vcsAdapter->getEvent(static::$pullRequestEventName, $this->pullRequestPayload()); + + $this->assertSame('opened', $result['action']); + $this->assertSame(self::EVENT_HEAD_BRANCH, $result['branch']); + $this->assertSame(self::EVENT_PULL_REQUEST_NUMBER, $result['pullRequestNumber']); + $this->assertSame(self::EVENT_REPOSITORY_ID, $result['repositoryId']); + $this->assertSame(self::EVENT_REPOSITORY_NAME, $result['repositoryName']); + $this->assertSame(self::EVENT_OWNER, $result['owner']); + $this->assertSame(self::EVENT_COMMIT_HASH, $result['commitHash']); + $this->assertFalse($result['external']); + } + + public function testGetEventPullRequestDetectsExternal(): void + { + $result = $this->vcsAdapter->getEvent(static::$pullRequestEventName, $this->pullRequestPayload(external: true)); + + $this->assertTrue($result['external']); + } + public function testGetEventInvalidPayload(): void { $this->expectException(Exception::class); From 6520bfaccc77f953845160f19309f8040400a1ce Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:51:31 +0530 Subject: [PATCH 17/20] Report a repository cleanup could not delete Review flagged that cleanup swallows every deletion failure, which is fair: a leaked repository was invisible. Throwing instead is what the previous round did, and a transient GitLab 500 during teardown then failed a test that had actually passed. Cleanup now stays out of the pass/fail decision and writes to the log when it could not delete something, so a leak is visible without a provider hiccup deciding whether a test failed. A repository that was never created is still nothing to report. --- tests/VCS/Base.php | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 63dd0bb1..b45970ff 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -353,18 +353,36 @@ protected function getLatestCommitEventually(string $repositoryName): array } /** - * Remove repositories a test created. Cleanup is best effort on purpose: a - * provider hiccup while tearing down should not fail an otherwise passing - * test, or replace the reason a failing one failed. Deleting is asserted by - * the delete tests instead. + * Remove repositories a test created. + * + * Cleanup never decides whether a test passed: a provider hiccup here would + * otherwise fail an passing test or replace the reason a failing one failed, + * and a GitLab 500 during teardown did exactly that. Deleting is asserted by + * the delete tests. A repository that was never created is nothing to report, + * but anything else is written to the log so a leak stays visible. */ protected function discardRepositories(string ...$repositoryNames): void { foreach ($repositoryNames as $repositoryName) { try { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } catch (\Throwable) { + } catch (RepositoryNotFound) { continue; + } catch (\Throwable $e) { + // Adapters carry the HTTP status as the exception code + if ($e->getCode() === 404) { + continue; + } + + \fwrite( + STDERR, + \sprintf( + "\nCleanup could not delete %s/%s and may have left it behind: %s\n", + static::$owner, + $repositoryName, + $e->getMessage() + ) + ); } } } From 33e50daf57e105e67c94bb5aa1b22153dc279f89 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 14:55:14 +0530 Subject: [PATCH 18/20] Retry a repository deletion before failing on it Review wants a cleanup failure to affect the result, and it is right that a repository left behind contaminates later runs. Making it throw is what an earlier round did, and a transient GitLab 500 during teardown then failed a test that had passed - which is why it became best effort. Retrying resolves both: a hiccup is retried away, and a deletion that still will not go through after three attempts fails the test with the names of the repositories it could not remove. A repository that was never created stays silent. --- tests/VCS/Base.php | 47 ++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index b45970ff..2e2d8271 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -355,34 +355,49 @@ protected function getLatestCommitEventually(string $repositoryName): array /** * Remove repositories a test created. * - * Cleanup never decides whether a test passed: a provider hiccup here would - * otherwise fail an passing test or replace the reason a failing one failed, - * and a GitLab 500 during teardown did exactly that. Deleting is asserted by - * the delete tests. A repository that was never created is nothing to report, - * but anything else is written to the log so a leak stays visible. + * A repository that was never created is nothing to clean up. Anything else + * is retried first, because a provider hiccup during teardown once failed a + * test that had passed, and then reported if it still will not delete - a + * repository left behind contaminates later runs, so it has to be visible + * in the result rather than only in the log. */ protected function discardRepositories(string ...$repositoryNames): void { + $failures = []; + foreach ($repositoryNames as $repositoryName) { + try { + $this->deleteRepositoryWithRetries($repositoryName); + } catch (\Throwable $e) { + $failures[] = "{$repositoryName}: {$e->getMessage()}"; + } + } + + if ($failures !== []) { + throw new Exception('Cleanup left repositories behind - ' . \implode(', ', $failures)); + } + } + + private function deleteRepositoryWithRetries(string $repositoryName, int $attempts = 3): void + { + for ($attempt = 1;; $attempt++) { try { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + + return; } catch (RepositoryNotFound) { - continue; + return; } catch (\Throwable $e) { // Adapters carry the HTTP status as the exception code if ($e->getCode() === 404) { - continue; + return; + } + + if ($attempt >= $attempts) { + throw $e; } - \fwrite( - STDERR, - \sprintf( - "\nCleanup could not delete %s/%s and may have left it behind: %s\n", - static::$owner, - $repositoryName, - $e->getMessage() - ) - ); + \usleep(2000000); } } } From a6fe10700153ebfc6d41130a8d18cb7dfb9cc8b1 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 15:12:17 +0530 Subject: [PATCH 19/20] Clean up what the moved tests left behind Removing tests by hand left GogsTest with headings introducing skip methods that are capabilities now, and GitHubTest with 44 blank lines at the end. Also collapses the other gaps the deletions opened up. --- tests/VCS/Adapter/ForgejoTest.php | 1 - tests/VCS/Adapter/GitHubTest.php | 50 ------------------------------- tests/VCS/Adapter/GitLabTest.php | 19 ------------ tests/VCS/Adapter/GogsTest.php | 12 -------- 4 files changed, 82 deletions(-) diff --git a/tests/VCS/Adapter/ForgejoTest.php b/tests/VCS/Adapter/ForgejoTest.php index e9e9a5ea..5ee0436e 100644 --- a/tests/VCS/Adapter/ForgejoTest.php +++ b/tests/VCS/Adapter/ForgejoTest.php @@ -16,7 +16,6 @@ class ForgejoTest extends GiteaTest protected static string $eventHeader = 'x-forgejo-event'; protected static string $signatureHeader = 'x-forgejo-signature'; - protected function setupAdapter(): void { if (empty(static::$accessToken)) { diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 4873c2af..90cdf087 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -62,9 +62,6 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - - - protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string { return (string) json_encode([ @@ -157,9 +154,6 @@ public function testGetEventInstallation(): void $this->assertSame('1234', $result['installationId']); } - - - public function testListBranchesPagination(): void { $repositoryName = 'test-list-branches-pages-' . \uniqid(); @@ -197,48 +191,4 @@ public function testListBranchesPagination(): void $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index a1f532b5..57aa031d 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -114,8 +114,6 @@ protected function pullRequestNumberOf(array $pullRequest): int return (int) $pullRequest['iid']; } - - protected function setupGitLab(): void { $tokenFile = '/gitlab-data/token.txt'; @@ -128,18 +126,6 @@ protected function setupGitLab(): void } } - - - - - - - - - - - - public function testGetEventPushMatchesCheckoutSha(): void { $payload = json_encode([ @@ -179,11 +165,6 @@ public function testGetEventPushMatchesCheckoutSha(): void $this->assertSame('http://example.com/commit/def456', $result['headCommitUrl']); } - - - - - public function testGetEventPullRequestActionMapping(): void { foreach (['open' => 'opened', 'reopen' => 'reopened', 'update' => 'synchronize', 'close' => 'closed', 'merge' => 'closed'] as $native => $mapped) { diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index 19623b0c..10c5a7d1 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -59,16 +59,4 @@ protected function setupGogs(): void } } } - - - // --- Skip tests for unsupported Gogs features --- - - // Pull request API - - // Commit status - - - - // Repository languages - } From 1db4b0201e3f52e38f8721de76e5dbd05f47fdc2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 15:14:27 +0530 Subject: [PATCH 20/20] Default every capability to supported in Base Per review: Base should describe the whole contract, and an adapter should be the thing that says where its provider falls short. Check runs, namespace listing, installation repository lookup and commit author links defaulted to unsupported, so a provider that cannot do them said nothing and the gap was invisible. They now default to supported, and each adapter states its own gaps - GitLab has no check runs, installation lookup or author links, Gitea has no check runs, namespaces or installation lookup and reports no author url. Anything a provider does not support is a line in its own class from now on. --- tests/VCS/Adapter/GitHubTest.php | 5 +---- tests/VCS/Adapter/GitLabTest.php | 5 ++++- tests/VCS/Adapter/GiteaTest.php | 5 ++++- tests/VCS/Base.php | 10 +++++----- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 90cdf087..17d57374 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -15,12 +15,9 @@ class GitHubTest extends Base /** @var array */ protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; - protected static bool $supportsInstallationRepository = true; - protected static bool $supportsCheckRuns = true; - protected static bool $reportsCommitAuthorAvatar = true; protected static string $avatarDomain = 'githubusercontent.com'; - protected static bool $reportsCommitAuthorUrl = true; protected static bool $supportsPullRequestCreation = false; + protected static bool $supportsNamespaceListing = false; protected static bool $supportsCommitStatusLookup = false; protected static bool $supportsTags = false; protected static bool $supportsUserLookup = false; diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 57aa031d..ec655a36 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -25,8 +25,11 @@ class GitLabTest extends Base protected static string $presignedTarballFragment = '/repository/archive.tar.gz?access_token='; protected static string $presignedZipballFragment = '/repository/archive.zip?access_token='; protected static string $repositoryNotFoundException = \Exception::class; - protected static bool $supportsNamespaceListing = true; protected static bool $deletesRepositoriesSynchronously = false; + protected static bool $supportsCheckRuns = false; + protected static bool $supportsInstallationRepository = false; + protected static bool $reportsCommitAuthorAvatar = false; + protected static bool $reportsCommitAuthorUrl = false; protected function signWebhookPayload(string $payload, string $secret): string { diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 99a91cc3..91d735f1 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -29,7 +29,10 @@ protected function signWebhookPayload(string $payload, string $secret): string return hash_hmac('sha256', $payload, $secret); } protected static string $avatarDomain = 'gravatar.com'; - protected static bool $reportsCommitAuthorAvatar = true; + protected static bool $supportsCheckRuns = false; + protected static bool $supportsNamespaceListing = false; + protected static bool $supportsInstallationRepository = false; + protected static bool $reportsCommitAuthorUrl = false; protected function setupAdapter(): void { diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 2e2d8271..032585ff 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -92,7 +92,7 @@ abstract class Base extends TestCase */ protected static bool $hasAccessToAllRepositories = true; - protected static bool $supportsInstallationRepository = false; + protected static bool $supportsInstallationRepository = true; /** * Exception the provider raises for a repository id that does not exist. @@ -125,9 +125,9 @@ abstract class Base extends TestCase protected static bool $rejectsInvalidRepositoryNames = true; - protected static bool $supportsCheckRuns = false; + protected static bool $supportsCheckRuns = true; - protected static bool $supportsNamespaceListing = false; + protected static bool $supportsNamespaceListing = true; /** * Whether the provider computes language stats out of band. GitHub does, @@ -157,9 +157,9 @@ abstract class Base extends TestCase * Whether the provider links the commit author back to an account. GitLab * reports neither, Gitea an avatar but no profile url. */ - protected static bool $reportsCommitAuthorAvatar = false; + protected static bool $reportsCommitAuthorAvatar = true; - protected static bool $reportsCommitAuthorUrl = false; + protected static bool $reportsCommitAuthorUrl = true; /** * Headers the provider sends its webhook event type and signature under.