diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 988cd3e7..cc0a7407 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -99,6 +99,42 @@ private function getNamespaceId(string $owner): string return $owner; } + private function findMrIidForNote(string $projectPath, string $commentId): ?int + { + $page = 1; + while (true) { + $url = "/projects/{$projectPath}/merge_requests?state=all&per_page=100&page={$page}"; + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list merge requests: HTTP {$statusCode}"); + } + + $mrs = $response['body'] ?? []; + + if (empty($mrs) || !is_array($mrs)) { + break; + } + + foreach ($mrs as $mr) { + $mrIid = $mr['iid'] ?? 0; + $noteUrl = "/projects/{$projectPath}/merge_requests/{$mrIid}/notes/{$commentId}"; + $noteResponse = $this->call(self::METHOD_GET, $noteUrl, ['PRIVATE-TOKEN' => $this->accessToken]); + if (($noteResponse['headers']['status-code'] ?? 0) === 200) { + return $mrIid; + } + } + + if (count($mrs) < 100) { + break; + } + $page++; + } + + return null; + } + public function createRepository(string $owner, string $repositoryName, bool $private): array { $namespaceId = (int) $this->getNamespaceId($owner); @@ -465,22 +501,81 @@ public function createWebhook(string $owner, string $repositoryName, string $url public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}/notes"; + + $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], ['body' => $comment]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create comment: HTTP {$statusCode}"); + } + + $responseBody = $response['body'] ?? []; + if (!array_key_exists('id', $responseBody)) { + throw new Exception("Comment creation response is missing comment ID."); + } + + return (string) ($responseBody['id'] ?? ''); } public function getComment(string $owner, string $repositoryName, string $commentId): string { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + + $mrIid = $this->findMrIidForNote($projectPath, $commentId); + if ($mrIid === null) { + return ''; + } + + $noteUrl = "/projects/{$projectPath}/merge_requests/{$mrIid}/notes/{$commentId}"; + $noteResponse = $this->call(self::METHOD_GET, $noteUrl, ['PRIVATE-TOKEN' => $this->accessToken]); + return $noteResponse['body']['body'] ?? ''; } public function updateComment(string $owner, string $repositoryName, int $commentId, string $comment): string { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + + $mrIid = $this->findMrIidForNote($projectPath, (string) $commentId); + if ($mrIid === null) { + throw new Exception("Failed to update comment: comment ID {$commentId} not found in any merge request."); + } + + $noteUrl = "/projects/{$projectPath}/merge_requests/{$mrIid}/notes/{$commentId}"; + $noteResponse = $this->call(self::METHOD_PUT, $noteUrl, ['PRIVATE-TOKEN' => $this->accessToken], ['body' => $comment]); + $noteHeaders = $noteResponse['headers'] ?? []; + if (($noteHeaders['status-code'] ?? 0) !== 200) { + throw new Exception("Failed to update comment: HTTP " . ($noteHeaders['status-code'] ?? 0)); + } + + return (string) $commentId; } public function getUser(string $username): array { - throw new Exception("Not implemented"); + $url = "/users?username=" . rawurlencode($username); + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get user: HTTP {$statusCode}"); + } + + $body = $response['body'] ?? []; + + // GitLab returns an array of users — return first match + if (empty($body[0])) { + throw new Exception("User not found: {$username}"); + } + + return $body[0]; } public function getOwnerName(string $installationId, ?int $repositoryId = null): string @@ -511,17 +606,123 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}"; + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get merge request: HTTP {$statusCode}"); + } + + $mr = $response['body'] ?? []; + + // Normalize to match expected shape (consistent with Gitea/GitHub) + return [ + 'number' => $mr['iid'] ?? 0, + 'title' => $mr['title'] ?? '', + 'state' => $mr['state'] ?? '', + 'head' => [ + 'ref' => $mr['source_branch'] ?? '', + 'sha' => $mr['sha'] ?? '', + ], + 'base' => [ + 'ref' => $mr['target_branch'] ?? '', + ], + ]; } public function getPullRequestFiles(string $owner, string $repositoryName, int $pullRequestNumber): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + + // Poll until diff is ready (patch_id_sha not null) + $maxAttempts = 10; + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + $mrResponse = $this->call( + self::METHOD_GET, + "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}", + ['PRIVATE-TOKEN' => $this->accessToken] + ); + $mrBody = $mrResponse['body'] ?? []; + if (($mrBody['patch_id_sha'] ?? null) !== null) { + break; + } + usleep(1000000); // 1 second + } + + // Fetch diffs with pagination + $allFiles = []; + $page = 1; + $perPage = 100; + + while (true) { + $url = "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}/diffs?page={$page}&per_page={$perPage}"; + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get merge request files: HTTP {$statusCode}"); + } + + $files = $response['body'] ?? []; + if (!is_array($files) || empty($files)) { + break; + } + + foreach ($files as $diff) { + $allFiles[] = [ + 'filename' => $diff['new_path'] ?? $diff['old_path'] ?? '', + ]; + } + + if (count($files) < $perPage) { + break; + } + $page++; + } + + return $allFiles; } public function getPullRequestFromBranch(string $owner, string $repositoryName, string $branch): array { - throw new Exception("Not implemented"); + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/merge_requests?state=opened&source_branch=" . urlencode($branch); + + $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list merge requests: HTTP {$statusCode}"); + } + + $body = $response['body'] ?? []; + if (empty($body[0])) { + return []; + } + + $mr = $body[0]; + + return [ + 'number' => $mr['iid'] ?? 0, + 'title' => $mr['title'] ?? '', + 'state' => $mr['state'] ?? '', + 'head' => [ + 'ref' => $mr['source_branch'] ?? '', + 'sha' => $mr['sha'] ?? '', + ], + 'base' => [ + 'ref' => $mr['target_branch'] ?? '', + ], + ]; } public function listBranches(string $owner, string $repositoryName): array @@ -704,14 +905,24 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri public function getEvent(string $event, string $payload): array { $payloadArray = json_decode($payload, true); - if ($payloadArray === false || $payloadArray === null) { + if ($payloadArray === null || !is_array($payloadArray)) { return []; } switch ($event) { case 'Push Hook': $commits = $payloadArray['commits'] ?? []; - $latestCommit = !empty($commits) ? $commits[0] : []; + $checkoutSha = $payloadArray['checkout_sha'] ?? ''; + $latestCommit = []; + foreach ($commits as $c) { + if (($c['id'] ?? '') === $checkoutSha) { + $latestCommit = $c; + break; + } + } + if (empty($latestCommit) && !empty($commits)) { + $latestCommit = $commits[0]; + } $ref = $payloadArray['ref'] ?? ''; // ref format: refs/heads/main $branch = str_replace('refs/heads/', '', $ref); diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index fa4600ff..8da97e59 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -130,7 +130,31 @@ public function testGetDeletedRepositoryFails(): void public function testGetPullRequestFromBranch(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-get-pr-from-branch-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'my-feature', static::$defaultBranch); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'feature.txt', 'content', 'Add feature', 'my-feature'); + + $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Feature MR', + 'my-feature', + static::$defaultBranch + ); + + $result = $this->vcsAdapter->getPullRequestFromBranch(static::$owner, $repositoryName, 'my-feature'); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + $this->assertArrayHasKey('head', $result); + $this->assertSame('my-feature', $result['head']['ref'] ?? ''); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGetOwnerName(): void @@ -204,12 +228,70 @@ public function testSearchRepositoriesWithSearch(): void public function testCreateComment(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-create-comment-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'test-branch', static::$defaultBranch); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'test.txt', 'test', 'Add test', 'test-branch'); + + $pr = $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Test PR', + 'test-branch', + static::$defaultBranch + ); + + $prNumber = $pr['iid'] ?? 0; + $this->assertGreaterThan(0, $prNumber); + + $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'Test comment'); + + $this->assertNotEmpty($commentId); + $this->assertIsString($commentId); + $this->assertIsNumeric($commentId); + + $retrieved = $this->vcsAdapter->getComment(static::$owner, $repositoryName, $commentId); + $this->assertSame('Test comment', $retrieved); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testUpdateComment(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-update-comment-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'test-branch', static::$defaultBranch); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'test.txt', 'test', 'Add test', 'test-branch'); + + $pr = $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Test PR', + 'test-branch', + static::$defaultBranch + ); + + $prNumber = $pr['iid'] ?? 0; + $this->assertGreaterThan(0, $prNumber); + + $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'Original comment'); + + $updatedCommentId = $this->vcsAdapter->updateComment(static::$owner, $repositoryName, (int)$commentId, 'Updated comment'); + + $this->assertSame($commentId, $updatedCommentId); + + $finalComment = $this->vcsAdapter->getComment(static::$owner, $repositoryName, $commentId); + $this->assertSame('Updated comment', $finalComment); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGenerateCloneCommand(): void @@ -601,7 +683,7 @@ public function testWebhookPullRequestEvent(): void }, 15000, 1000); $this->assertSame('merge_request', $payload['object_kind'] ?? ''); - $this->assertSame('open', $payload['object_attributes']['action'] ?? ''); + $this->assertContains($payload['object_attributes']['action'] ?? '', ['open', 'update']); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); @@ -735,22 +817,118 @@ public function testGetRepositoryNameWithInvalidId(): void public function testGetComment(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-get-comment-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'test-branch', static::$defaultBranch); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'test.txt', 'test', 'Add test', 'test-branch'); + + $pr = $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Test PR', + 'test-branch', + static::$defaultBranch + ); + + $prNumber = $pr['iid'] ?? 0; + $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'Test comment'); + + $result = $this->vcsAdapter->getComment(static::$owner, $repositoryName, $commentId); + + $this->assertIsString($result); + $this->assertSame('Test comment', $result); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGetPullRequest(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-get-pull-request-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $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', 'feature content', 'Add feature', 'feature-branch'); + + $pr = $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Test MR', + 'feature-branch', + static::$defaultBranch, + 'Test MR description' + ); + + $prNumber = $pr['iid'] ?? 0; + $this->assertGreaterThan(0, $prNumber); + + $result = $this->vcsAdapter->getPullRequest(static::$owner, $repositoryName, $prNumber); + + $this->assertIsArray($result); + $this->assertArrayHasKey('number', $result); + $this->assertArrayHasKey('title', $result); + $this->assertArrayHasKey('state', $result); + $this->assertArrayHasKey('head', $result); + $this->assertArrayHasKey('base', $result); + $this->assertSame($prNumber, $result['number']); + $this->assertSame('Test MR', $result['title']); + $this->assertSame('opened', $result['state']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGetPullRequestFiles(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-get-pull-request-files-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $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', 'feature content', 'Add feature', 'feature-branch'); + + $pr = $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Test MR Files', + 'feature-branch', + static::$defaultBranch + ); + + $prNumber = $pr['iid'] ?? 0; + $this->assertGreaterThan(0, $prNumber); + + $result = []; + $this->assertEventually(function () use (&$result, $repositoryName, $prNumber) { + $result = $this->vcsAdapter->getPullRequestFiles(static::$owner, $repositoryName, $prNumber); + $this->assertNotEmpty($result); + }, 15000, 1000); + + $this->assertIsArray($result); + $filenames = array_column($result, 'filename'); + $this->assertContains('feature.txt', $filenames); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGetPullRequestWithInvalidNumber(): void { - $this->markTestSkipped('Not implemented for GitLab yet'); + $repositoryName = 'test-get-pull-request-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->getPullRequest(static::$owner, $repositoryName, 99999); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } } public function testGetRepositoryTree(): void @@ -884,9 +1062,11 @@ public function testListRepositoryLanguages(): void $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'main.php', 'vcsAdapter->createFile(static::$owner, $repositoryName, 'script.js', 'console.log("test");'); - sleep(5); // ← increase from 2 to 5 - - $languages = $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName); + $languages = []; + $this->assertEventually(function () use (&$languages, $repositoryName) { + $languages = $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName); + $this->assertNotEmpty($languages); + }, 30000, 2000); $this->assertIsArray($languages); $this->assertNotEmpty($languages); @@ -940,4 +1120,178 @@ public function testListRepositoryContents(): void $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } + + public function testGetUser(): void + { + $result = $this->vcsAdapter->getUser('root'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + $this->assertArrayHasKey('username', $result); + } + + public function testGetUserWithInvalidUsername(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->getUser('non-existent-user-' . \uniqid()); + } + + public function testCreatePrivateRepository(): void + { + $repositoryName = 'test-create-private-repository-' . \uniqid(); + + $result = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, true); + + try { + $this->assertIsArray($result); + $this->assertSame('private', $result['visibility']); + + $fetched = $this->vcsAdapter->getRepository(static::$owner, $repositoryName); + $this->assertSame('private', $fetched['visibility']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryWithNonExistingOwner(): void + { + $repositoryName = 'test-non-existing-owner-' . \uniqid(); + + $this->expectException(\Exception::class); + $this->vcsAdapter->getRepository('non-existing-owner-' . \uniqid(), $repositoryName); + } + + public function testCreateRepositoryWithInvalidName(): void + { + $repositoryName = 'invalid name with spaces'; + + try { + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->fail('Expected exception for invalid repository name'); + } catch (\Exception $e) { + $this->assertTrue(true); + } + } + + public function testDeleteRepositoryTwiceFails(): void + { + $repositoryName = 'test-delete-repository-twice-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + + $this->expectException(\Exception::class); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + + public function testDeleteNonExistingRepositoryFails(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->deleteRepository(static::$owner, 'non-existing-repo-' . \uniqid()); + } + + public function testGetPullRequestFromBranchNoPR(): void + { + $repositoryName = 'test-get-pr-no-pr-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'lonely-branch', static::$defaultBranch); + + $result = $this->vcsAdapter->getPullRequestFromBranch(static::$owner, $repositoryName, 'lonely-branch'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testCreateCommentInvalidPR(): void + { + $repositoryName = 'test-comment-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $this->expectException(\Exception::class); + $this->vcsAdapter->createComment(static::$owner, $repositoryName, 99999, 'Test comment'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCommentInvalidId(): void + { + $repositoryName = 'test-get-comment-invalid-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + try { + $result = $this->vcsAdapter->getComment(static::$owner, $repositoryName, '99999999'); + + $this->assertIsString($result); + $this->assertSame('', $result); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetEventPushMatchesCheckoutSha(): void + { + $payload = json_encode([ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'checkout_sha' => 'def456', + 'project' => [ + 'name' => 'test-repo', + 'namespace' => 'test-org', + ], + 'commits' => [ + [ + 'id' => 'abc123', + 'message' => 'Older commit', + 'url' => 'http://example.com/commit/abc123', + 'author' => ['name' => 'Old Author'], + ], + [ + 'id' => 'def456', + 'message' => 'Head commit', + 'url' => 'http://example.com/commit/def456', + 'author' => ['name' => 'Head Author'], + ], + ], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $result = $this->vcsAdapter->getEvent('Push Hook', $payload); + + $this->assertIsArray($result); + $this->assertSame('def456', $result['commitHash']); + $this->assertSame('Head Author', $result['commitAuthor']); + $this->assertSame('Head commit', $result['commitMessage']); + $this->assertSame('http://example.com/commit/def456', $result['commitUrl']); + } + + public function testValidateWebhookEventUsesPlainToken(): void + { + $secret = 'my-secret-token'; + $payload = '{"object_kind":"push"}'; + + $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) + ); + } }