diff --git a/composer.json b/composer.json index 00d46355..86e23a33 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "require": { "php": ">=8.0", "adhocore/jwt": "^1.1", - "utopia-php/cache": "1.0.*" + "utopia-php/cache": "1.0.*", + "utopia-php/fetch": "1.0.*" }, "require-dev": { "phpunit/phpunit": "^9.4", diff --git a/composer.lock b/composer.lock index 4643220a..b0861589 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "53c307bde5f5612420f5359faeea0b92", + "content-hash": "c852a00fe5b6a9a36484ffc38940afcd", "packages": [ { "name": "adhocore/jwt", @@ -1980,6 +1980,46 @@ }, "time": "2026-03-12T03:39:09+00:00" }, + { + "name": "utopia-php/fetch", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/fetch.git", + "reference": "89760880428d869d9da0ebc45bab207befda4c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/89760880428d869d9da0ebc45bab207befda4c4d", + "reference": "89760880428d869d9da0ebc45bab207befda4c4d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "laravel/pint": "^1.5.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5", + "swoole/ide-helper": "^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Fetch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library that provides an interface for making HTTP Requests.", + "support": { + "issues": "https://github.com/utopia-php/fetch/issues", + "source": "https://github.com/utopia-php/fetch/tree/1.0.0" + }, + "time": "2026-01-19T09:50:33+00:00" + }, { "name": "utopia-php/pools", "version": "1.0.3", @@ -4066,12 +4106,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.0" }, - "platform-dev": {}, - "plugin-api-version": "2.9.0" + "platform-dev": [], + "plugin-api-version": "2.6.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 95f4099a..77a3aa8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,11 +12,14 @@ services: - TESTS_GITHUB_APP_IDENTIFIER - TESTS_GITHUB_INSTALLATION_ID - TESTS_GITEA_URL=http://gitea:3000 + - TESTS_GITEA_REQUEST_CATCHER_URL=http://request-catcher:5000 depends_on: gitea: condition: service_healthy gitea-bootstrap: condition: service_completed_successfully + request-catcher: + condition: service_started gitea: image: gitea/gitea:1.21.5 @@ -25,6 +28,10 @@ services: - USER_GID=1000 - GITEA__database__DB_TYPE=sqlite3 - GITEA__security__INSTALL_LOCK=true + - GITEA__webhook__ALLOWED_HOST_LIST=* + - GITEA__webhook__SKIP_TLS_VERIFY=true + - GITEA__webhook__DELIVER_TIMEOUT=10 + - GITEA__server__LOCAL_ROOT_URL=http://gitea:3000/ ports: - "3000:3000" volumes: @@ -54,6 +61,9 @@ services: TOKEN=$$(su git -c \"gitea admin user generate-access-token --username $$GITEA_ADMIN_USERNAME --token-name $$GITEA_ADMIN_USERNAME-token --scopes all --raw\") && echo $$TOKEN > /data/gitea/token.txt " - + request-catcher: + image: appwrite/requestcatcher:1.1.0 + ports: + - "5000:5000" volumes: gitea-data: \ No newline at end of file diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 6a4a7edf..5ce6c439 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -70,4 +70,17 @@ abstract public function createBranch(string $owner, string $repositoryName, str * @return array Created PR details */ abstract public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array; + + /** + * Create a webhook on a repository + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $url Webhook URL to send events to + * @param string $secret Webhook secret for signature validation + * @param array $events Events to trigger the webhook + * @return int Webhook ID + */ + abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int; + } diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 958cac09..2ac02d56 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -109,6 +109,16 @@ public function createPullRequest(string $owner, string $repositoryName, string throw new Exception('Not implemented'); } + /** + * Create a webhook on a repository + * + * Note: Not applicable for GitHub - webhooks are managed via GitHub Apps + */ + public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int + { + throw new Exception('Not applicable for GitHub - webhooks are managed via GitHub Apps'); + } + /** * Create a file in a repository * diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index aedf6c7e..1b8f4ba5 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -473,6 +473,43 @@ public function createPullRequest(string $owner, string $repositoryName, string return $responseBody; } + /** + * Create a webhook on a repository + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $url Webhook URL to send events to + * @param string $secret Webhook secret for signature validation + * @param array $events Events to trigger the webhook + * @return int Webhook ID + */ + public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int + { + $response = $this->call( + self::METHOD_POST, + "/repos/{$owner}/{$repositoryName}/hooks", + ['Authorization' => "token $this->accessToken"], + [ + 'type' => 'gitea', + 'active' => true, + 'events' => $events, + 'config' => [ + 'url' => $url, + 'content_type' => 'json', + 'secret' => $secret, + ], + ] + ); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to create webhook: HTTP {$responseHeadersStatusCode}"); + } + + return (int) ($response['body']['id'] ?? 0); + } + public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string { $url = "/repos/{$owner}/{$repositoryName}/issues/{$pullRequestNumber}/comments"; @@ -739,13 +776,142 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri throw new Exception("Not implemented yet"); } + /** + * Parses webhook event payload + * + * @param string $event Type of event: push, pull_request, etc + * @param string $payload The webhook payload received from Gitea + * @return array Parsed payload as an array + */ public function getEvent(string $event, string $payload): array { - throw new Exception("Not implemented yet"); + $payload = json_decode($payload, true); + + if ($payload === null || !is_array($payload)) { + throw new Exception("Invalid payload."); + } + + switch ($event) { + case 'push': + $payloadRepository = $payload['repository'] ?? []; + $payloadRepositoryOwner = $payloadRepository['owner'] ?? []; + $payloadSender = $payload['sender'] ?? []; + $payloadHeadCommit = $payload['head_commit'] ?? []; + $payloadHeadCommitAuthor = $payloadHeadCommit['author'] ?? []; + + $branchCreated = $payload['created'] ?? false; + $branchDeleted = $payload['deleted'] ?? false; + $repositoryId = strval($payloadRepository['id'] ?? ''); + $repositoryName = $payloadRepository['name'] ?? ''; + $branch = str_replace('refs/heads/', '', $payload['ref'] ?? ''); + $repositoryUrl = $payloadRepository['html_url'] ?? ''; + $branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . "/src/branch/" . $branch : ''; + $commitHash = $payload['after'] ?? ''; + $owner = $payloadRepositoryOwner['login'] ?? ''; + $authorUrl = $payloadSender['html_url'] ?? ''; + $authorAvatarUrl = $payloadSender['avatar_url'] ?? ''; + $headCommitAuthorName = $payloadHeadCommitAuthor['name'] ?? ''; + $headCommitAuthorEmail = $payloadHeadCommitAuthor['email'] ?? ''; + $headCommitMessage = $payloadHeadCommit['message'] ?? ''; + $headCommitUrl = $payloadHeadCommit['url'] ?? ''; + + $affectedFiles = []; + foreach (($payload['commits'] ?? []) as $commit) { + foreach (($commit['added'] ?? []) as $added) { + $affectedFiles[$added] = true; + } + + foreach (($commit['removed'] ?? []) as $removed) { + $affectedFiles[$removed] = true; + } + + foreach (($commit['modified'] ?? []) as $modified) { + $affectedFiles[$modified] = true; + } + } + + return [ + 'branchCreated' => $branchCreated, + 'branchDeleted' => $branchDeleted, + 'branch' => $branch, + 'branchUrl' => $branchUrl, + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', // Gitea doesn't have installations + 'commitHash' => $commitHash, + 'owner' => $owner, + 'authorUrl' => $authorUrl, + 'authorAvatarUrl' => $authorAvatarUrl, + 'headCommitAuthorName' => $headCommitAuthorName, + 'headCommitAuthorEmail' => $headCommitAuthorEmail, + 'headCommitMessage' => $headCommitMessage, + 'headCommitUrl' => $headCommitUrl, + 'external' => false, + 'pullRequestNumber' => '', + 'action' => '', + 'affectedFiles' => \array_keys($affectedFiles), + ]; + + case 'pull_request': + $payloadRepository = $payload['repository'] ?? []; + $payloadRepositoryOwner = $payloadRepository['owner'] ?? []; + $payloadSender = $payload['sender'] ?? []; + $payloadPullRequest = $payload['pull_request'] ?? []; + $payloadPullRequestHead = $payloadPullRequest['head'] ?? []; + $payloadPullRequestHeadRepo = $payloadPullRequestHead['repo'] ?? []; + $payloadPullRequestUser = $payloadPullRequest['user'] ?? []; + $payloadPullRequestBase = $payloadPullRequest['base'] ?? []; + + $repositoryId = strval($payloadRepository['id'] ?? ''); + $branch = $payloadPullRequestHead['ref'] ?? ''; + $repositoryName = $payloadRepository['name'] ?? ''; + $repositoryUrl = $payloadRepository['html_url'] ?? ''; + $branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . "/src/branch/" . $branch : ''; + $pullRequestNumber = $payload['number'] ?? ''; + $action = $payload['action'] ?? ''; + $owner = $payloadRepositoryOwner['login'] ?? ''; + $authorUrl = $payloadSender['html_url'] ?? ''; + $authorAvatarUrl = $payloadPullRequestUser['avatar_url'] ?? ''; + $commitHash = $payloadPullRequestHead['sha'] ?? ''; + $headCommitUrl = $repositoryUrl ? $repositoryUrl . "/commit/" . $commitHash : ''; + + // Check if PR is from a fork (external) + $headRepoFullName = $payloadPullRequestHeadRepo['full_name'] ?? ''; + $baseRepoFullName = $payloadRepository['full_name'] ?? ''; + $external = !empty($headRepoFullName) && !empty($baseRepoFullName) && $headRepoFullName !== $baseRepoFullName; + + return [ + 'branch' => $branch, + 'branchUrl' => $branchUrl, + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', // Gitea doesn't have installations + 'commitHash' => $commitHash, + 'owner' => $owner, + 'authorUrl' => $authorUrl, + 'authorAvatarUrl' => $authorAvatarUrl, + 'headCommitUrl' => $headCommitUrl, + 'external' => $external, + 'pullRequestNumber' => $pullRequestNumber, + 'action' => $action, + ]; + } + + return []; } + /** + * Validate webhook event + * + * @param string $payload Raw body of HTTP request + * @param string $signature Signature provided by Gitea in X-Gitea-Signature header + * @param string $signatureKey Webhook secret configured on Gitea + * @return bool + */ public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool { - throw new Exception("Not implemented yet"); + return hash_equals($signature, hash_hmac('sha256', $payload, $signatureKey)); } } diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 9038fcee..a518656a 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -26,9 +26,9 @@ public function setUp(): void $this->vcsAdapter->initializeVariables(installationId: $installationId, privateKey: $privateKey, appId: $appId, accessToken: '', refreshToken: ''); } - public function testGetEvent(): void + public function testGetEventPush(): void { - $payload_push = '{ + $payload = '{ "created": false, "ref": "refs/heads/main", "before": "1234", @@ -91,7 +91,19 @@ public function testGetEvent(): void } }'; - $payload_pull_request = '{ + $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 + { + $payload = '{ "action": "opened", "number": 1, "pull_request": { @@ -133,7 +145,15 @@ public function testGetEvent(): void } }'; - $payload_uninstall = '{ + $result = $this->vcsAdapter->getEvent('pull_request', $payload); + + $this->assertSame('opened', $result['action']); + $this->assertSame(1, $result['pullRequestNumber']); + } + + public function testGetEventInstallation(): void + { + $payload = '{ "action": "deleted", "installation": { "id": 1234, @@ -141,24 +161,12 @@ public function testGetEvent(): void "login": "vermakhushboo" } } - } - '; - - $pushResult = $this->vcsAdapter->getEvent('push', $payload_push); - $this->assertSame('main', $pushResult['branch']); - $this->assertSame('603754812', $pushResult['repositoryId']); - $this->assertCount(3, $pushResult['affectedFiles']); - $this->assertSame('src/lib.js', $pushResult['affectedFiles'][0]); - $this->assertSame('README.md', $pushResult['affectedFiles'][1]); - $this->assertSame('src/main.js', $pushResult['affectedFiles'][2]); - - $pullRequestResult = $this->vcsAdapter->getEvent('pull_request', $payload_pull_request); - $this->assertSame('opened', $pullRequestResult['action']); - $this->assertSame(1, $pullRequestResult['pullRequestNumber']); - - $uninstallResult = $this->vcsAdapter->getEvent('installation', $payload_uninstall); - $this->assertSame('deleted', $uninstallResult['action']); - $this->assertSame('1234', $uninstallResult['installationId']); + }'; + + $result = $this->vcsAdapter->getEvent('installation', $payload); + + $this->assertSame('deleted', $result['action']); + $this->assertSame('1234', $result['installationId']); } public function testGetComment(): void diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 88e75341..e9be9676 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -55,6 +55,7 @@ private function setupGitea(): void } } } + public function testCreateRepository(): void { $owner = self::$owner; @@ -641,10 +642,229 @@ public function testGetLatestCommitWithInvalidBranch(): void } } - public function testGetEvent(): void + public function testGetEventPush(): void { - $this->markTestSkipped('Will be implemented in follow-up PR'); + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'before' => 'abc123', + 'after' => 'def456', + 'created' => false, + 'deleted' => false, + 'repository' => [ + 'id' => 123, + 'name' => 'test-repo', + 'html_url' => 'http://gitea:3000/test-owner/test-repo', + 'owner' => [ + 'login' => 'test-owner', + ], + ], + 'sender' => [ + 'login' => 'pusher-user', + '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'], + ], + ], + ]); + + 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('main', $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 + { + $payload = json_encode([ + 'action' => 'opened', + 'number' => 42, + 'pull_request' => [ + 'id' => 1, + 'number' => 42, + 'state' => 'open', + 'title' => 'Test PR', + 'head' => [ + 'ref' => 'feature-branch', + 'sha' => 'abc123', + 'repo' => [ + 'full_name' => 'test-owner/test-repo', + ], + 'user' => [ + 'login' => 'pr-author', + ], + ], + 'base' => [ + 'ref' => 'main', + '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' => 'main', + ], + 'user' => [ + 'avatar_url' => 'http://gitea:3000/avatars/external', + ], + ], + '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', + ], + ]); + + 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 testGetEventUnsupportedEvent(): void + { + $payload = json_encode(['test' => 'data']); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $result = $this->vcsAdapter->getEvent('unsupported_event', $payload); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + public function testSearchRepositories(): void { // Create multiple repositories @@ -1013,4 +1233,116 @@ public function testListRepositoryLanguagesEmptyRepo(): void $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); } + + public function testWebhookPushEvent(): void + { + $repositoryName = 'test-webhook-push-' . \uniqid(); + $secret = 'test-webhook-secret-' . \uniqid(); + + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + $catcherUrl = System::getEnv('TESTS_GITEA_REQUEST_CATCHER_URL', 'http://request-catcher:5000') ?? ''; + $this->deleteLastWebhookRequest(); + $this->vcsAdapter->createWebhook(self::$owner, $repositoryName, $catcherUrl . '/webhook', $secret); + + // Trigger a real push by creating a file + $this->vcsAdapter->createFile( + self::$owner, + $repositoryName, + 'README.md', + '# Webhook Test', + 'Initial commit' + ); + + // Wait for push webhook to arrive automatically + $webhookData = []; + $this->assertEventually(function () use (&$webhookData) { + $webhookData = $this->getLastWebhookRequest(); + $this->assertNotEmpty($webhookData, 'No webhook received'); + $this->assertNotEmpty($webhookData['data'] ?? '', 'Webhook payload is empty'); + $this->assertSame('push', $webhookData['headers']['X-Gitea-Event'] ?? '', 'Expected push event'); + }, 15000, 500); + + $payload = $webhookData['data']; + $headers = $webhookData['headers'] ?? []; + $signature = $headers['X-Gitea-Signature'] ?? ''; + + $this->assertNotEmpty($signature, 'Missing X-Gitea-Signature header'); + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), + 'Webhook signature validation failed' + ); + + $event = $this->vcsAdapter->getEvent('push', $payload); + $this->assertIsArray($event); + $this->assertSame('main', $event['branch']); + $this->assertSame($repositoryName, $event['repositoryName']); + $this->assertSame(self::$owner, $event['owner']); + $this->assertNotEmpty($event['commitHash']); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + + public function testWebhookPullRequestEvent(): void + { + $repositoryName = 'test-webhook-pr-' . \uniqid(); + $secret = 'test-webhook-secret-' . \uniqid(); + + $this->vcsAdapter->createRepository(self::$owner, $repositoryName, false); + + try { + // Create all files BEFORE configuring webhook + // so those push events don't pollute the catcher + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createBranch(self::$owner, $repositoryName, 'feature-branch', 'main'); + $this->vcsAdapter->createFile(self::$owner, $repositoryName, 'feature.txt', 'content', 'Add feature', 'feature-branch'); + + $catcherUrl = System::getEnv('TESTS_GITEA_REQUEST_CATCHER_URL', 'http://request-catcher:5000') ?? ''; + $this->vcsAdapter->createWebhook(self::$owner, $repositoryName, $catcherUrl . '/webhook', $secret); + + // Clear after setup so only PR event will arrive + $this->deleteLastWebhookRequest(); + + // Trigger real PR event + $this->vcsAdapter->createPullRequest( + self::$owner, + $repositoryName, + 'Test Webhook PR', + 'feature-branch', + 'main' + ); + + // Wait for pull_request webhook to arrive automatically + $webhookData = []; + $this->assertEventually(function () use (&$webhookData) { + $webhookData = $this->getLastWebhookRequest(); + $this->assertNotEmpty($webhookData, 'No webhook received'); + $this->assertNotEmpty($webhookData['data'] ?? '', 'Webhook payload is empty'); + $this->assertSame('pull_request', $webhookData['headers']['X-Gitea-Event'] ?? '', 'Expected pull_request event'); + }, 15000, 500); + + $payload = $webhookData['data']; + $headers = $webhookData['headers'] ?? []; + $signature = $headers['X-Gitea-Signature'] ?? ''; + + $this->assertNotEmpty($signature, 'Missing X-Gitea-Signature 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(self::$owner, $event['owner']); + $this->assertContains($event['action'], ['opened', 'synchronized']); + $this->assertGreaterThan(0, $event['pullRequestNumber']); + } finally { + $this->vcsAdapter->deleteRepository(self::$owner, $repositoryName); + } + } + } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index cb3f1db7..8e17c8cc 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -4,6 +4,7 @@ use Exception; use PHPUnit\Framework\TestCase; +use Utopia\Fetch\Client; use Utopia\System\System; use Utopia\VCS\Adapter\Git; use Utopia\VCS\Adapter\Git\GitHub; @@ -25,8 +26,6 @@ abstract public function testGenerateCloneCommand(): void; abstract public function testGenerateCloneCommandWithCommitHash(): void; - abstract public function testGetEvent(): void; - abstract public function testGetRepositoryName(): void; abstract public function testGetComment(): void; @@ -35,6 +34,59 @@ abstract public function testGetPullRequest(): void; abstract public function testGetRepositoryTree(): void; + /** @return array */ + protected function getLastWebhookRequest(): array + { + $catcherUrl = System::getEnv('TESTS_GITEA_REQUEST_CATCHER_URL', 'http://request-catcher:5000') ?? ''; + + $client = new Client(); + $response = $client->fetch( + url: "{$catcherUrl}/__last_request__", + method: 'GET' + ); + + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + return []; + } + + $body = $response->text(); + + if (empty($body)) { + return []; + } + + return json_decode($body, true) ?? []; + } + + protected function assertEventually(callable $probe, int $timeoutMs = 15000, int $waitMs = 500): void + { + $start = microtime(true) * 1000; + $lastException = null; + + while ((microtime(true) * 1000 - $start) < $timeoutMs) { + try { + $probe(); + return; + } catch (\Throwable $e) { + $lastException = $e; + usleep($waitMs * 1000); + } + } + + throw $lastException ?? new \Exception('assertEventually timed out'); + } + + protected function deleteLastWebhookRequest(): void + { + $catcherUrl = System::getEnv('TESTS_GITEA_REQUEST_CATCHER_URL', 'http://request-catcher:5000') ?? ''; + + $client = new Client(); + $client->fetch( + url: "{$catcherUrl}/__clear__", + method: 'DELETE' + ); + } + public function testGetPullRequestFromBranch(): void { $result = $this->vcsAdapter->getPullRequestFromBranch('vermakhushboo', 'basic-js-crud', 'test');