diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 3a759b0c..367c068d 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -23,6 +23,9 @@ abstract class Adapter public const TYPE_GIT = 'git'; public const TYPE_SVN = 'svn'; + public const WEBHOOK_SCOPE_INSTALLATION = 'installation'; + public const WEBHOOK_SCOPE_REPOSITORY = 'repository'; + protected bool $selfSigned = true; protected string $endpoint; @@ -194,14 +197,23 @@ abstract public function updateComment(string $owner, string $repositoryName, st abstract public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string; /** - * Parses webhook event payload + * Validates a webhook payload signature. + * + * Default covers unprefixed HMAC-SHA256, the scheme most providers use. + * Override for providers with a different scheme (a prefixed digest, a + * plain secret-token compare, etc). * * @param string $payload Raw body of HTTP request * @param string $signature Signature provided by Git provider in header * @param string $signatureKey Webhook secret configured on Git provider * @return bool */ - abstract public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool; + public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool + { + $expected = \hash_hmac('sha256', $payload, $signatureKey); + + return \hash_equals($expected, $signature); + } /** * Parses webhook event payload @@ -212,6 +224,57 @@ abstract public function validateWebhookEvent(string $payload, string $signature */ abstract public function getEvent(string $event, string $payload): array; + /** + * HTTP header name carrying the webhook event type (e.g. 'x-github-event'). + */ + abstract public function getEventHeaderName(): string; + + /** + * HTTP header name carrying webhook verification data. Usually an HMAC + * signature of the payload (e.g. 'x-hub-signature-256'), but some + * providers (e.g. GitLab's 'x-gitlab-token') send a plain shared secret + * instead — check validateWebhookEvent()'s implementation per adapter + * before assuming uniform HMAC comparison. + */ + abstract public function getSignatureHeaderName(): string; + + /** + * Webhook scopes this adapter can deliver events through. + * + * WEBHOOK_SCOPE_INSTALLATION: events arrive platform-wide once the + * integration itself is installed (e.g. a GitHub App) -- no per-repository + * registration needed. + * WEBHOOK_SCOPE_REPOSITORY: createWebhook() must be called on each + * individual repository to receive its events. + * + * An adapter may support more than one scope. Consumers that only need + * one webhook per connected repository should prefer + * WEBHOOK_SCOPE_INSTALLATION when present. + * + * @return array + */ + abstract public function getSupportedWebhookScopes(): array; + + /** + * Browser-facing URL for a repository's home page. + */ + abstract public function getRepositoryUrl(string $owner, string $repositoryName): string; + + /** + * Browser-facing URL for a branch within a repository. + */ + abstract public function getBranchUrl(string $owner, string $repositoryName, string $branch): string; + + /** + * Browser-facing URL for a commit within a repository. + */ + abstract public function getCommitUrl(string $owner, string $repositoryName, string $commitHash): string; + + /** + * Browser-facing URL for a file at a given ref within a repository. + */ + abstract public function getFileUrl(string $owner, string $repositoryName, string $reference): string; + /** * Fetches repository name using repository id * diff --git a/src/VCS/Adapter/Git/Forgejo.php b/src/VCS/Adapter/Git/Forgejo.php index c7f09fee..485770c6 100644 --- a/src/VCS/Adapter/Git/Forgejo.php +++ b/src/VCS/Adapter/Git/Forgejo.php @@ -20,4 +20,14 @@ protected function getHookType(): string { return 'forgejo'; } + + public function getEventHeaderName(): string + { + return 'x-forgejo-event'; + } + + public function getSignatureHeaderName(): string + { + return 'x-forgejo-signature'; + } } diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 9c54df63..ae8fa5b9 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -121,12 +121,37 @@ public function createPullRequest(string $owner, string $repositoryName, string /** * 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'); + $response = $this->call( + self::METHOD_POST, + "/repos/{$owner}/{$repositoryName}/hooks", + ['Authorization' => "Bearer $this->accessToken"], + [ + 'name' => 'web', + '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}", $responseHeadersStatusCode); + } + + $id = $response['body']['id'] ?? null; + if ($id === null) { + throw new Exception('Webhook created but response did not include an id'); + } + + return (int) $id; } /** @@ -1195,6 +1220,41 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri return $fullCommand; } + public function getEventHeaderName(): string + { + return 'x-github-event'; + } + + public function getSignatureHeaderName(): string + { + return 'x-hub-signature-256'; + } + + public function getSupportedWebhookScopes(): array + { + return [self::WEBHOOK_SCOPE_INSTALLATION, self::WEBHOOK_SCOPE_REPOSITORY]; + } + + public function getRepositoryUrl(string $owner, string $repositoryName): string + { + return "https://github.com/{$owner}/{$repositoryName}"; + } + + public function getBranchUrl(string $owner, string $repositoryName, string $branch): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/tree/{$branch}"; + } + + public function getCommitUrl(string $owner, string $repositoryName, string $commitHash): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/commit/{$commitHash}"; + } + + public function getFileUrl(string $owner, string $repositoryName, string $reference): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/blob/{$reference}"; + } + /** * Parses webhook event payload * @@ -1347,7 +1407,9 @@ public function getEvent(string $event, string $payload): array */ public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool { - return $signature === ('sha256=' . hash_hmac('sha256', $payload, $signatureKey)); + $expected = 'sha256=' . hash_hmac('sha256', $payload, $signatureKey); + + return hash_equals($expected, $signature); } public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 6bc5c2b0..51b20f49 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -38,6 +38,41 @@ public function getName(): string return 'gitlab'; } + public function getEventHeaderName(): string + { + return 'x-gitlab-event'; + } + + public function getSignatureHeaderName(): string + { + return 'x-gitlab-token'; + } + + public function getSupportedWebhookScopes(): array + { + return [self::WEBHOOK_SCOPE_REPOSITORY]; + } + + public function getRepositoryUrl(string $owner, string $repositoryName): string + { + return "{$this->gitlabUrl}/{$owner}/{$repositoryName}"; + } + + public function getBranchUrl(string $owner, string $repositoryName, string $branch): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/-/tree/{$branch}"; + } + + public function getCommitUrl(string $owner, string $repositoryName, string $commitHash): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/-/commit/{$commitHash}"; + } + + public function getFileUrl(string $owner, string $repositoryName, string $reference): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/-/blob/{$reference}"; + } + public function initializeVariables(string $installationId, string $privateKey, ?string $appId = null, ?string $accessToken = null, ?string $refreshToken = null): void { if (!empty($accessToken)) { @@ -1005,6 +1040,10 @@ public function getEvent(string $event, string $payload): array } } + /** + * GitLab doesn't sign the payload -- it just echoes back the configured + * secret token verbatim, so there's no HMAC to compute here. + */ public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool { return hash_equals($signatureKey, $signature); diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index c4e20b66..194d4ac2 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -663,10 +663,66 @@ public function getUser(string $username): array return $response['body'] ?? []; } + protected function getAuthenticatedUserLogin(): string + { + $response = $this->call(self::METHOD_GET, '/user', ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get authenticated user: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); + } + + $login = $response['body']['login'] ?? ''; + if (empty($login)) { + throw new Exception("Authenticated user login missing or empty in response"); + } + + return $login; + } + + public function getEventHeaderName(): string + { + return 'x-gitea-event'; + } + + public function getSignatureHeaderName(): string + { + return 'x-gitea-signature'; + } + + public function getSupportedWebhookScopes(): array + { + return [self::WEBHOOK_SCOPE_REPOSITORY]; + } + + public function getRepositoryUrl(string $owner, string $repositoryName): string + { + return "{$this->giteaUrl}/{$owner}/{$repositoryName}"; + } + + public function getBranchUrl(string $owner, string $repositoryName, string $branch): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/src/branch/{$branch}"; + } + + public function getCommitUrl(string $owner, string $repositoryName, string $commitHash): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/commit/{$commitHash}"; + } + + public function getFileUrl(string $owner, string $repositoryName, string $reference): string + { + // Gitea requires a ref-type qualifier (branch/commit/tag) for a canonical + // path, which this method's signature doesn't carry; the untyped /src/{ref} + // form redirects to the correct canonical URL for whichever type it resolves to. + return $this->getRepositoryUrl($owner, $repositoryName) . "/src/{$reference}"; + } + public function getOwnerName(string $installationId, ?int $repositoryId = null): string { if ($repositoryId === null || $repositoryId <= 0) { - throw new Exception("repositoryId is required for this adapter"); + return $this->getAuthenticatedUserLogin(); } $url = "/repositories/{$repositoryId}"; @@ -1174,19 +1230,6 @@ public function getEvent(string $event, string $payload): array 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 - { - return hash_equals($signature, hash_hmac('sha256', $payload, $signatureKey)); - } - /** * Create a tag in a repository * diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 6418c8c4..0a666b7d 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -22,6 +22,21 @@ protected function getHookType(): string return 'gogs'; } + public function getEventHeaderName(): string + { + return 'x-gogs-event'; + } + + public function getSignatureHeaderName(): string + { + return 'x-gogs-signature'; + } + + public function getBranchUrl(string $owner, string $repositoryName, string $branch): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/src/{$branch}"; + } + /** * Create new repository * @@ -167,7 +182,7 @@ public function getRepositoryName(string $repositoryId): string public function getOwnerName(string $installationId, ?int $repositoryId = null): string { if ($repositoryId === null || $repositoryId <= 0) { - throw new Exception("repositoryId is required for this adapter"); + return $this->getAuthenticatedUserLogin(); } $repo = $this->findRepositoryById($repositoryId); diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index ac9de15b..2a9dbfd0 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -1268,18 +1268,16 @@ public function testGetOwnerName(): void public function testGetOwnerNameWithZeroRepositoryId(): void { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('repositoryId is required for this adapter'); + $owner = $this->vcsAdapter->getOwnerName('', 0); - $this->vcsAdapter->getOwnerName('', 0); + $this->assertNotEmpty($owner); } public function testGetOwnerNameWithoutRepositoryId(): void { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('repositoryId is required for this adapter'); + $owner = $this->vcsAdapter->getOwnerName(''); - $this->vcsAdapter->getOwnerName(''); + $this->assertNotEmpty($owner); } public function testGetOwnerNameWithInvalidRepositoryId(): void @@ -1291,10 +1289,9 @@ public function testGetOwnerNameWithInvalidRepositoryId(): void public function testGetOwnerNameWithNullRepositoryId(): void { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('repositoryId is required for this adapter'); + $owner = $this->vcsAdapter->getOwnerName('', null); - $this->vcsAdapter->getOwnerName('', null); + $this->assertNotEmpty($owner); } public function testGetUser(): void diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 5aadff0b..74439db3 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -89,6 +89,25 @@ protected function deleteLastWebhookRequest(): void ); } + public function testGetEventHeaderName(): void + { + $this->assertIsString($this->vcsAdapter->getEventHeaderName()); + $this->assertNotEmpty($this->vcsAdapter->getEventHeaderName()); + } + + public function testGetSignatureHeaderName(): void + { + $this->assertIsString($this->vcsAdapter->getSignatureHeaderName()); + $this->assertNotEmpty($this->vcsAdapter->getSignatureHeaderName()); + } + + public function testGetSupportedWebhookScopes(): void + { + $scopes = $this->vcsAdapter->getSupportedWebhookScopes(); + $this->assertIsArray($scopes); + $this->assertNotEmpty($scopes); + } + public function testGetPullRequestFromBranch(): void { $result = $this->vcsAdapter->getPullRequestFromBranch('vermakhushboo', 'basic-js-crud', 'test');