diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 764affea..8769b94b 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,106 @@ 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()); + } + + /** + * 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()); + } + + /** + * 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 * @@ -109,6 +210,21 @@ abstract public function createTag(string $owner, string $repositoryName, string */ 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..6f052061 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"; @@ -736,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/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 6c97a2ab..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 */ @@ -1060,7 +1046,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) { 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/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 e6b69ffb..17d57374 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -7,13 +7,31 @@ 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]; + + protected static string $avatarDomain = 'githubusercontent.com'; + 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; + protected static bool $computesLanguagesAsynchronously = true; + protected static bool $supportsWebhookDelivery = false; + protected static bool $resolvesOwnerFromRepositoryId = false; + protected static bool $rejectsInvalidRepositoryNames = false; + + 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'; protected function setupAdapter(): void { @@ -41,104 +59,76 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - public function testWebhookHeaderNames(): void + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string { - $this->assertSame('x-github-event', $this->vcsAdapter->getEventHeaderName()); - $this->assertSame('x-hub-signature-256', $this->vcsAdapter->getSignatureHeaderName()); - } - - public function testGetEventPush(): void - { - $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 @@ -161,49 +151,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 - { - $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 { $repositoryName = 'test-list-branches-pages-' . \uniqid(); @@ -218,6 +165,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); @@ -236,502 +188,4 @@ public function testListBranchesPagination(): void $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } - - 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); - } - } - - - - - public function testGetOwnerName(): void - { - $result = $this->vcsAdapter->getOwnerName(static::$installationId); - - $this->assertIsString($result); - $this->assertNotEmpty($result); - $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 - { - $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 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); - } - } - - 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 20862600..ec655a36 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -13,6 +13,28 @@ 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 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 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 + { + return $secret; + } protected function setupAdapter(): void { @@ -55,23 +77,44 @@ 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->assertSame('x-gitlab-event', $this->vcsAdapter->getEventHeaderName()); - $this->assertSame('x-gitlab-token', $this->vcsAdapter->getSignatureHeaderName()); + $this->assertArrayHasKey('namespace', $repository); + $this->assertIsArray($repository['namespace']); + $this->assertArrayHasKey('path', $repository['namespace']); + + return (string) $repository['namespace']['path']; } - public function testListTagsCommitlessRepository(): void + /** + * GitLab reports visibility as a string rather than a boolean flag. + * + * @param array $repository + */ + protected function isPrivate(array $repository): bool { - $repositoryName = 'test-list-tags-commitless-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->assertArrayHasKey('visibility', $repository); + $this->assertIsString($repository['visibility']); - 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); - } + return $repository['visibility'] === 'private'; + } + + /** + * GitLab numbers merge requests per project, under 'iid'. + * + * @param array $pullRequest + */ + protected function pullRequestNumberOf(array $pullRequest): int + { + $this->assertArrayHasKey('iid', $pullRequest); + $this->assertIsNumeric($pullRequest['iid']); + + return (int) $pullRequest['iid']; } protected function setupGitLab(): void @@ -86,338 +129,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 - { - $repositoryName = 'test-get-commit-statuses-' . \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->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'); - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['commitHash']; - - $result = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - 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 - { - $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 - { - $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']); - } - - - 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 { $payload = json_encode([ @@ -457,66 +168,6 @@ 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 - { - $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 { foreach (['open' => 'opened', 'reopen' => 'reopened', 'update' => 'synchronize', 'close' => 'closed', 'merge' => 'closed'] as $native => $mapped) { @@ -535,148 +186,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([ - 'object_kind' => 'merge_request', - 'project' => ['id' => 1, 'name' => 'r', 'namespace' => 'o', 'web_url' => 'http://example.com/o/r'], - 'object_attributes' => [ - 'iid' => 1, - 'action' => 'open', - 'source_branch' => 'f', - 'target_branch' => 'main', - 'source_project_id' => 456, - 'target_project_id' => 123, + $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, + ]], ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $result = $this->vcsAdapter->getEvent('Merge Request Hook', $payload); - $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 - { - $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 + protected function pullRequestPayload(bool $external = false): string { - $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); + return (string) json_encode([ + 'object_kind' => 'merge_request', + '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' => self::EVENT_PULL_REQUEST_NUMBER, + 'title' => 'Test MR', + // GitLab calls it 'open' and normalizes to 'opened' + 'action' => 'open', + '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], + ], + ], + ]); } - } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 0a887940..91d735f1 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -2,14 +2,11 @@ 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; class GiteaTest extends Base { @@ -17,10 +14,26 @@ 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'; + /** @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 static bool $supportsCheckRuns = false; + protected static bool $supportsNamespaceListing = false; + protected static bool $supportsInstallationRepository = false; + protected static bool $reportsCommitAuthorUrl = false; + protected function setupAdapter(): void { if (empty(static::$accessToken)) { @@ -58,577 +71,79 @@ 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 - { - $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 testWebhookHeaderNames(): void - { - $this->assertSame(static::$eventHeader, $this->vcsAdapter->getEventHeaderName()); - $this->assertSame(static::$signatureHeader, $this->vcsAdapter->getSignatureHeaderName()); - } - - public function testGetCommitAuthorAvatar(): void - { - $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 testHasAccessToAllRepositories(): void - { - $this->assertTrue($this->vcsAdapter->hasAccessToAllRepositories()); - } - - public function testGetRepositoryTreeWithSlashInBranchName(): void + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string { - $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 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'); + $repositoryUrl = 'http://gitea:3000/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; - $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 - { - $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']); - } - - 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 testGetEventInvalidPayload(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid payload'); - - $this->vcsAdapter->getEvent('push', 'invalid json'); - } - - 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 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); - - $this->vcsAdapter->getOwnerName('', 999999999); - } - - public function testGetOwnerNameWithNullRepositoryId(): void - { - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', null)); - } - - 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'); - - $commit = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch); - $commitHash = $commit['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); - } - } - - public function testCreateRepositoryWithInvalidName(): void - { - $this->expectException(Exception::class); - $this->vcsAdapter->createRepository(static::$owner, 'invalid name with spaces', false); - } - } diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index cdb29a1a..10c5a7d1 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -13,6 +13,12 @@ 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 bool $createsEmptyRepositories = false; protected static string $eventHeader = 'x-gogs-event'; protected static string $signatureHeader = 'x-gogs-signature'; @@ -53,79 +59,4 @@ 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'); - } - - // 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 - // 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 f00b280b..032585ff 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'; @@ -21,19 +39,160 @@ 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. + */ + protected static string $userHandleField = 'username'; + + /** + * State the provider reports for a freshly opened pull request. + */ + protected static string $openPullRequestState = 'open'; + + /** + * Scopes the provider accepts webhooks at. Only GitHub registers them + * once per installation as well as per repository. + * + * @var array + */ + 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 = true; + + /** + * Exception the provider raises for a repository id that does not exist. + * + * @var class-string<\Throwable> + */ + 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; + + protected static bool $supportsCheckRuns = true; + + protected static bool $supportsNamespaceListing = true; + + /** + * 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; + + /** + * 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. + */ + protected static bool $reportsCommitAuthorAvatar = true; + + protected static bool $reportsCommitAuthorUrl = 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. */ abstract protected function setupAdapter(): void; /** - * Webhook payloads and signature schemes are provider specific. + * Sign a payload the way the provider signs its webhooks. */ - abstract public function testGetEventPush(): void; + abstract protected function signWebhookPayload(string $payload, string $secret): string; - abstract public function testGetEventPullRequest(): void; + /** + * 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 protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string; - abstract public function testValidateWebhookEvent(): 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 { @@ -89,41 +248,79 @@ 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']; - } - - $namespace = $repository['namespace'] ?? []; - if (\is_array($namespace) && !empty($namespace['path'])) { - return (string) $namespace['path']; - } + $this->assertArrayHasKey('owner', $repository); + $this->assertIsArray($repository['owner']); + $this->assertArrayHasKey('login', $repository['owner']); - $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']); + + 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']; + } + + /** + * Every provider reports pushed_at as a timestamp, including for a + * repository that has no commits yet. + * + * @param array $repository + */ + protected function assertPushedAt(array $repository): void + { + $this->assertArrayHasKey('pushed_at', $repository); + $this->assertNotFalse( + \strtotime((string) $repository['pushed_at']), + 'pushed_at is not a parseable timestamp' + ); + } + + /** + * @param array $commit + */ + protected function assertCommitAuthorLinks(array $commit): void + { + if (static::$reportsCommitAuthorAvatar) { + $this->assertNotEmpty($commit['commitAuthorAvatar']); } - if (\array_key_exists('visibility', $repository)) { - return $repository['visibility'] === 'private'; + if (static::$reportsCommitAuthorUrl) { + $this->assertNotEmpty($commit['commitAuthorUrl']); } + } - $this->fail('Repository reports neither a private flag nor a visibility'); + 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 @@ -155,6 +352,56 @@ protected function getLatestCommitEventually(string $repositoryName): array return $commit; } + /** + * Remove repositories a test created. + * + * 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) { + return; + } catch (\Throwable $e) { + // Adapters carry the HTTP status as the exception code + if ($e->getCode() === 404) { + return; + } + + if ($attempt >= $attempts) { + throw $e; + } + + \usleep(2000000); + } + } + } + protected function deleteLastWebhookRequest(): void { $catcherUrl = System::getEnv('TESTS_REQUEST_CATCHER_URL', 'http://request-catcher:5000'); @@ -166,23 +413,15 @@ protected function deleteLastWebhookRequest(): void ); } - public function testGetEventHeaderName(): void - { - $this->assertIsString($this->vcsAdapter->getEventHeaderName()); - $this->assertNotEmpty($this->vcsAdapter->getEventHeaderName()); - } - - public function testGetSignatureHeaderName(): void + public function testWebhookHeaderNames(): 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 { - $scopes = $this->vcsAdapter->getSupportedWebhookScopes(); - $this->assertIsArray($scopes); - $this->assertNotEmpty($scopes); + $this->assertSame(static::$supportedWebhookScopes, $this->vcsAdapter->getSupportedWebhookScopes()); } public function testCreateRepository(): void @@ -195,14 +434,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->assertPushedAt($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)); @@ -210,7 +443,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); } } @@ -229,7 +462,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); } } @@ -243,12 +476,9 @@ 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->assertPushedAt($result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -305,14 +535,15 @@ 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); $this->assertIsString($result); $this->assertSame($repositoryName, $result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -353,7 +584,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); } } @@ -369,7 +600,7 @@ public function testGetRepositoryTreeWithInvalidBranch(): void $this->assertIsArray($tree); $this->assertEmpty($tree); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -392,7 +623,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); } } @@ -409,7 +640,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); } } @@ -424,7 +655,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); } } @@ -457,7 +688,7 @@ public function testListRepositoryContents(): void $this->assertArrayHasKey('size', $item); } } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -473,12 +704,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); @@ -487,20 +720,30 @@ 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); } 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); @@ -509,7 +752,7 @@ public function testListRepositoryLanguagesEmptyRepo(): void $this->assertIsArray($languages); $this->assertEmpty($languages); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -535,12 +778,14 @@ public function testListBranches(): void $this->assertNotEmpty($branches); $this->assertContains(static::$defaultBranch, $branches); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } 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); @@ -550,12 +795,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); @@ -580,7 +827,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); } } @@ -597,7 +844,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); } } @@ -628,10 +875,11 @@ 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']); + $this->assertCommitAuthorLinks($result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -652,8 +900,9 @@ 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']); + $this->assertCommitAuthorLinks($commit1); $commit1Hash = $commit1['commitHash']; @@ -669,7 +918,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); } } @@ -684,12 +933,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::$supportsCommitStatuses, 'commit statuses'); + $repositoryName = 'test-update-commit-status-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -709,13 +960,18 @@ public function testUpdateCommitStatus(): void 'ci/build' ); + if (!static::$supportsCommitStatusLookup) { + return; + } + $statuses = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); $this->assertIsArray($statuses); $this->assertNotEmpty($statuses); $written = null; foreach ($statuses as $status) { - if (($status['context'] ?? '') === 'ci/build') { + $this->assertArrayHasKey('context', $status); + if ($status['context'] === 'ci/build') { $written = $status; break; } @@ -725,7 +981,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); } } @@ -759,7 +1015,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)); } @@ -797,7 +1053,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)); } @@ -821,8 +1077,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)); @@ -830,6 +1088,27 @@ 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)); + } + public function testGetOwnerName(): void { $repositoryName = 'test-get-owner-name-' . \uniqid(); @@ -838,11 +1117,79 @@ 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)); + // 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->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(); + + 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->discardRepositories($repositoryName); + } + } + + public function testSearchRepositoriesMatchesName(): void + { + $match = 'test-search-match-' . \uniqid(); + $other = 'test-search-other-' . \uniqid(); + + 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); + $names = array_column($result['items'], 'name'); + $this->assertContains($match, $names); + }, 60000, 2000); + + $this->assertNotContains($other, $names); + } finally { + $this->discardRepositories($match, $other); } } @@ -851,10 +1198,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); @@ -871,20 +1218,17 @@ 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->assertPushedAt($repository); } } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repo1Name); - $this->vcsAdapter->deleteRepository(static::$owner, $repo2Name); + $this->discardRepositories($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); @@ -902,7 +1246,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,14 +1259,16 @@ 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); + $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); @@ -939,7 +1285,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) { @@ -951,12 +1297,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); @@ -964,12 +1312,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); @@ -991,14 +1341,16 @@ 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); + $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); @@ -1012,12 +1364,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); @@ -1034,7 +1388,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'); @@ -1042,12 +1396,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); @@ -1064,7 +1420,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); @@ -1072,12 +1428,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); @@ -1094,7 +1452,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'); @@ -1104,7 +1462,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); } } @@ -1118,7 +1476,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); } } @@ -1133,23 +1491,28 @@ 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); $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 { + $this->skipUnlessSupported(static::$supportsUserLookup, 'looking up users'); + $this->expectException(Exception::class); $this->vcsAdapter->getUser('non-existent-user-' . \uniqid()); } @@ -1164,15 +1527,866 @@ 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); } } - public function testGetEventUnsupportedEvent(): void - { - $payload = json_encode(['test' => 'data']); + /** + * @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' + ); - if ($payload === false) { + 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->discardRepositories($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->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'); + + $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->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'); + + $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->discardRepositories($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->discardRepositories($repositoryName); + } + } + + public function testCreateTag(): void + { + $this->skipUnlessSupported(static::$supportsTags, 'creating tags'); + + $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->discardRepositories($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']); + } finally { + $this->discardRepositories($repo1, $repo2); + } + } + + 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); + + 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->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); + + 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->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); + + 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->discardRepositories($repositoryName); + } + } + + 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 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 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 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); + $this->vcsAdapter->getEvent('push', 'invalid json'); + } + + public function testGetEventUnsupportedEvent(): void + { + $payload = json_encode(['test' => 'data']); + + if ($payload === false) { $this->fail('Failed to encode JSON payload'); } @@ -1199,7 +2413,7 @@ public function testCreateFile(): void $this->assertIsArray($result); $this->assertNotEmpty($result); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1232,7 +2446,7 @@ public function testCreateFileOnBranch(): void ); $this->assertSame('# Feature', $content['content']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -1257,7 +2471,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); } } @@ -1271,6 +2485,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); @@ -1283,12 +2499,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(),