From c6e3769aa9bdc87334b38a60620e5d96cf4ef760 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 13:25:13 +0530 Subject: [PATCH 01/14] feat: add provider capability methods for URL building and webhook config Adds getRepositoryUrl/getBranchUrl/getCommitUrl/getFileUrl, getEventHeaderName/getSignatureHeaderName, and requiresRepositoryWebhook to the Adapter contract, implemented on GitHub, Gitea (inherited by Forgejo/Gogs), and GitLab. This moves provider-specific knowledge (browser URL shapes, webhook header names, whether per-repository webhooks are needed) into the adapters themselves, so consumers don't need to keep a parallel metadata registry describing each provider's conventions. Also: - Gitea::getOwnerName() falls back to GET /user when no repositoryId is available, instead of throwing unconditionally. - Gitea::listBranches() and Gogs::listBranches() gain the same perPage/page/search parameters GitHub::listBranches() already has, removing the last signature mismatch between adapters. --- src/VCS/Adapter.php | 37 ++++++++++++++++++ src/VCS/Adapter/Git/GitHub.php | 35 +++++++++++++++++ src/VCS/Adapter/Git/GitLab.php | 35 +++++++++++++++++ src/VCS/Adapter/Git/Gitea.php | 69 +++++++++++++++++++++++++++++++--- src/VCS/Adapter/Git/Gogs.php | 8 +++- 5 files changed, 176 insertions(+), 8 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 3a759b0c..560d5b60 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -212,6 +212,43 @@ 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 the webhook payload signature (e.g. 'x-hub-signature-256'). + */ + abstract public function getSignatureHeaderName(): string; + + /** + * Whether this provider needs an explicit webhook created per repository + * to receive push/pull_request events. False for providers that deliver + * events platform-wide once installed (e.g. a GitHub App). + */ + abstract public function requiresRepositoryWebhook(): bool; + + /** + * 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/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 9c54df63..d85b01d7 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -1195,6 +1195,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 requiresRepositoryWebhook(): bool + { + return false; + } + + 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 * diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 6bc5c2b0..34d132f8 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 requiresRepositoryWebhook(): bool + { + return true; + } + + 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)) { diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index c4e20b66..8c30d739 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -663,10 +663,63 @@ 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 requiresRepositoryWebhook(): bool + { + return true; + } + + 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 + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/src/branch/{$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}"; @@ -767,14 +820,14 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, * @param string $repositoryName Name of the repository * @return array Array of branch names */ - public function listBranches(string $owner, string $repositoryName): array + public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array { $allBranches = []; - $perPage = 50; + $fetchPerPage = 50; $maxPages = 100; for ($currentPage = 1; $currentPage <= $maxPages; $currentPage++) { - $url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$perPage}"; + $url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$fetchPerPage}"; $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"], decode: false); @@ -806,12 +859,16 @@ public function listBranches(string $owner, string $repositoryName): array } } - if ($pageCount < $perPage) { + if ($pageCount < $fetchPerPage) { break; } } - return $allBranches; + if (!empty($search)) { + $allBranches = \array_values(\array_filter($allBranches, fn ($branch) => \str_contains($branch, $search))); + } + + return \array_slice($allBranches, ($page - 1) * $perPage, $perPage); } /** diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 6418c8c4..d6a66ba9 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -505,7 +505,7 @@ public function getCommitStatuses(string $owner, string $repositoryName, string * * @return array */ - public function listBranches(string $owner, string $repositoryName): array + public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array { $url = "/repos/{$owner}/{$repositoryName}/branches"; @@ -535,7 +535,11 @@ public function listBranches(string $owner, string $repositoryName): array } } - return $branches; + if (!empty($search)) { + $branches = \array_values(\array_filter($branches, fn ($branch) => \str_contains($branch, $search))); + } + + return \array_slice($branches, ($page - 1) * $perPage, $perPage); } /** From 634b1e633925a4a60640127ef94ed1ec2e95b282 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 13:48:17 +0530 Subject: [PATCH 02/14] fix: address CI failures and review findings - getFileUrl on Gitea/Gogs hardcoded /src/branch/ for any reference type; commit hashes and tags 404. Switched to the untyped /src/{ref} path, which Gitea redirects to the correct canonical URL. - Widened GitLab::listBranches to the same perPage/page/search signature as GitHub/Gitea/Gogs, and updated the abstract Adapter declaration to match all four implementations. - Updated the three GiteaTest cases that asserted getOwnerName throws without a repositoryId; that's now the intended /user fallback. --- src/VCS/Adapter.php | 5 ++++- src/VCS/Adapter/Git/GitLab.php | 14 +++++++++----- src/VCS/Adapter/Git/Gitea.php | 5 ++++- tests/VCS/Adapter/GiteaTest.php | 15 ++++++--------- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 560d5b60..b3a9cbf8 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -262,9 +262,12 @@ abstract public function getRepositoryName(string $repositoryId): string; * * @param string $owner Owner name of the repository * @param string $repositoryName Name of the repository + * @param int $perPage Number of branches to return + * @param int $page Page number, 1-indexed + * @param string $search Substring filter on branch name; empty returns all branches * @return array List of branch names as array */ - abstract public function listBranches(string $owner, string $repositoryName): array; + abstract public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array; /** * Lists tags for a given repository, optionally filtered by a glob pattern. diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 34d132f8..02cad3b8 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -769,15 +769,15 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, ]; } - public function listBranches(string $owner, string $repositoryName): array + public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array { $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $branches = []; - $page = 1; + $fetchPage = 1; do { - $pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$page}"; + $pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$fetchPage}"; $response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -791,10 +791,14 @@ public function listBranches(string $owner, string $repositoryName): array foreach ($responseBody as $branch) { $branches[] = $branch['name'] ?? ''; } - $page++; + $fetchPage++; } while (count($responseBody) === 100); - return $branches; + if (!empty($search)) { + $branches = \array_values(\array_filter($branches, fn ($branch) => \str_contains($branch, $search))); + } + + return \array_slice($branches, ($page - 1) * $perPage, $perPage); } public function listTags(string $owner, string $repositoryName, string $search = ''): array diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 8c30d739..3131928a 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -713,7 +713,10 @@ public function getCommitUrl(string $owner, string $repositoryName, string $comm public function getFileUrl(string $owner, string $repositoryName, string $reference): string { - return $this->getRepositoryUrl($owner, $repositoryName) . "/src/branch/{$reference}"; + // 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 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 From bd27fde4d716a79f9555acf85d8193478d9791e7 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 13:53:30 +0530 Subject: [PATCH 03/14] fix: apply the same getOwnerName /user fallback to Gogs Gogs overrides getOwnerName independently of Gitea (doesn't inherit it), so the earlier fallback fix only applied to Gitea/Forgejo. Reuses the inherited getAuthenticatedUserLogin() helper. --- src/VCS/Adapter/Git/Gogs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index d6a66ba9..a8de3e9b 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -167,7 +167,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); From f17dceec11584488724a6631a13bf3a2fdd821f3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 14:03:56 +0530 Subject: [PATCH 04/14] fix: override webhook header names on Gogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gogs extends Gitea but sends X-Gogs-Event/X-Gogs-Signature, not X-Gitea-Event/X-Gitea-Signature — GogsTest already declared this via its own webhookEventHeader/webhookSignatureHeader constants and its live webhook test passes against a real Gogs server, confirming the real header names. Without this override, any consumer routing or validating Gogs webhooks via getEventHeaderName()/ getSignatureHeaderName() would silently miss every event. --- src/VCS/Adapter/Git/Gogs.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index a8de3e9b..2d2b0193 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -22,6 +22,16 @@ protected function getHookType(): string return 'gogs'; } + public function getEventHeaderName(): string + { + return 'x-gogs-event'; + } + + public function getSignatureHeaderName(): string + { + return 'x-gogs-signature'; + } + /** * Create new repository * From 6ecf85d95a7db6a3040807534701b066bb09c652 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 14:09:47 +0530 Subject: [PATCH 05/14] fix: Gogs::getLatestCommit no longer truncates branch existence check listBranches now defaults to perPage=100, so calling it directly to verify a branch exists (as getLatestCommit did) silently missed any branch past position 100. Extracted the raw unpaginated fetch into fetchAllBranches() and pointed the existence check at that instead, leaving listBranches's own pagination unaffected. --- src/VCS/Adapter/Git/Gogs.php | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index 2d2b0193..ac899c74 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -271,7 +271,7 @@ public function getCommit(string $owner, string $repositoryName, string $commitH public function getLatestCommit(string $owner, string $repositoryName, string $branch): array { // Gogs ignores sha param — verify branch exists first - $branches = $this->listBranches($owner, $repositoryName); + $branches = $this->fetchAllBranches($owner, $repositoryName); if (!in_array($branch, $branches, true)) { throw new Exception("Branch '{$branch}' not found"); } @@ -516,6 +516,22 @@ public function getCommitStatuses(string $owner, string $repositoryName, string * @return array */ public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array + { + $branches = $this->fetchAllBranches($owner, $repositoryName); + + if (!empty($search)) { + $branches = \array_values(\array_filter($branches, fn ($branch) => \str_contains($branch, $search))); + } + + return \array_slice($branches, ($page - 1) * $perPage, $perPage); + } + + /** + * Fetches every branch name for a repository, unpaginated. + * + * @return array + */ + private function fetchAllBranches(string $owner, string $repositoryName): array { $url = "/repos/{$owner}/{$repositoryName}/branches"; @@ -545,11 +561,7 @@ public function listBranches(string $owner, string $repositoryName, int $perPage } } - if (!empty($search)) { - $branches = \array_values(\array_filter($branches, fn ($branch) => \str_contains($branch, $search))); - } - - return \array_slice($branches, ($page - 1) * $perPage, $perPage); + return $branches; } /** From d66ed653a96d239da46d23e6086341419dcea103 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 14:17:32 +0530 Subject: [PATCH 06/14] fix: correct Forgejo webhook headers and Gogs branch URL - Forgejo::getEventHeaderName/getSignatureHeaderName were inherited from Gitea (x-gitea-*), but Forgejo sends X-Forgejo-Event/ X-Forgejo-Signature (confirmed via ForgejoTest's own webhookEventHeader/webhookSignatureHeader constants). - Gogs::getBranchUrl was inherited from Gitea and emitted /src/branch/{branch}, which 404s on Gogs; Gogs' web UI omits the 'branch' path segment, same class of issue as the earlier getFileUrl fix. --- src/VCS/Adapter/Git/Forgejo.php | 10 ++++++++++ src/VCS/Adapter/Git/Gogs.php | 6 ++++++ 2 files changed, 16 insertions(+) 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/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index ac899c74..c20fb00d 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -32,6 +32,12 @@ public function getSignatureHeaderName(): string return 'x-gogs-signature'; } + public function getBranchUrl(string $owner, string $repositoryName, string $branch): string + { + // Gogs' web UI omits the "branch" path segment Gitea uses; /src/branch/{branch} 404s here. + return $this->getRepositoryUrl($owner, $repositoryName) . "/src/{$branch}"; + } + /** * Create new repository * From 5459e9128c34cde1990f1d9f5e424a59781d94a3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 14:27:06 +0530 Subject: [PATCH 07/14] refactor: scope listBranches pagination to adapters that need it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening the abstract Adapter::listBranches contract to perPage/page/ search forced GitLab into the same signature even though no current caller uses pagination on it — GitLab isn't in Appwrite's provider registry. That coupling also forced GitLab's implementation into the same fetch-all-then-slice pattern flagged as inefficient in review, for a capability nothing exercises. Reverted the abstract declaration and GitLab's implementation back to the original 2-param form. Gitea and Gogs keep their widened signatures — those serve a real caller (Appwrite's branch-search endpoint). GitHub already had a wider override than the (now un-widened) abstract before this PR; that's legal PHP and unaffected. Also clarified getSignatureHeaderName()'s docblock: it's usually an HMAC signature, but GitLab's 'x-gitlab-token' is a plain shared secret, not a signature — callers shouldn't assume uniform HMAC comparison across all providers. --- src/VCS/Adapter.php | 11 ++++++----- src/VCS/Adapter/Git/GitLab.php | 14 +++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index b3a9cbf8..8ec6ec0d 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -218,7 +218,11 @@ abstract public function getEvent(string $event, string $payload): array; abstract public function getEventHeaderName(): string; /** - * HTTP header name carrying the webhook payload signature (e.g. 'x-hub-signature-256'). + * 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; @@ -262,12 +266,9 @@ abstract public function getRepositoryName(string $repositoryId): string; * * @param string $owner Owner name of the repository * @param string $repositoryName Name of the repository - * @param int $perPage Number of branches to return - * @param int $page Page number, 1-indexed - * @param string $search Substring filter on branch name; empty returns all branches * @return array List of branch names as array */ - abstract public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array; + abstract public function listBranches(string $owner, string $repositoryName): array; /** * Lists tags for a given repository, optionally filtered by a glob pattern. diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 02cad3b8..34d132f8 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -769,15 +769,15 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, ]; } - public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array + public function listBranches(string $owner, string $repositoryName): array { $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $branches = []; - $fetchPage = 1; + $page = 1; do { - $pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$fetchPage}"; + $pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$page}"; $response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -791,14 +791,10 @@ public function listBranches(string $owner, string $repositoryName, int $perPage foreach ($responseBody as $branch) { $branches[] = $branch['name'] ?? ''; } - $fetchPage++; + $page++; } while (count($responseBody) === 100); - if (!empty($search)) { - $branches = \array_values(\array_filter($branches, fn ($branch) => \str_contains($branch, $search))); - } - - return \array_slice($branches, ($page - 1) * $perPage, $perPage); + return $branches; } public function listTags(string $owner, string $repositoryName, string $search = ''): array From c3bc979e355bb1c1c8600082bb375af10740c785 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 9 Jul 2026 14:33:44 +0530 Subject: [PATCH 08/14] refactor: drop listBranches pagination from this PR entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appwrite's Branches/XList.php never actually calls listBranches with search/pagination on non-GitHub adapters — it kept an instanceof GitHub guard specifically because Gitea/Gogs didn't have it, and never removed that guard. So the perPage/page/search plumbing added to Gitea and Gogs in this PR served no real caller. Reverted Gitea::listBranches and Gogs::listBranches (and the fetchAllBranches extraction it required) back to their exact upstream form. This PR is now scoped to what it set out to do: the seven new capability methods (URL builders, webhook headers, requiresRepositoryWebhook) plus the getOwnerName /user fallback. Branch-listing pagination parity across adapters can be its own PR if a real caller ever needs it. --- src/VCS/Adapter/Git/Gitea.php | 14 +++++--------- src/VCS/Adapter/Git/Gogs.php | 20 ++------------------ 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 3131928a..80bdda20 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -823,14 +823,14 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, * @param string $repositoryName Name of the repository * @return array Array of branch names */ - public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array + public function listBranches(string $owner, string $repositoryName): array { $allBranches = []; - $fetchPerPage = 50; + $perPage = 50; $maxPages = 100; for ($currentPage = 1; $currentPage <= $maxPages; $currentPage++) { - $url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$fetchPerPage}"; + $url = "/repos/{$owner}/{$repositoryName}/branches?page={$currentPage}&limit={$perPage}"; $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"], decode: false); @@ -862,16 +862,12 @@ public function listBranches(string $owner, string $repositoryName, int $perPage } } - if ($pageCount < $fetchPerPage) { + if ($pageCount < $perPage) { break; } } - if (!empty($search)) { - $allBranches = \array_values(\array_filter($allBranches, fn ($branch) => \str_contains($branch, $search))); - } - - return \array_slice($allBranches, ($page - 1) * $perPage, $perPage); + return $allBranches; } /** diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index c20fb00d..34d3dc26 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -277,7 +277,7 @@ public function getCommit(string $owner, string $repositoryName, string $commitH public function getLatestCommit(string $owner, string $repositoryName, string $branch): array { // Gogs ignores sha param — verify branch exists first - $branches = $this->fetchAllBranches($owner, $repositoryName); + $branches = $this->listBranches($owner, $repositoryName); if (!in_array($branch, $branches, true)) { throw new Exception("Branch '{$branch}' not found"); } @@ -521,23 +521,7 @@ public function getCommitStatuses(string $owner, string $repositoryName, string * * @return array */ - public function listBranches(string $owner, string $repositoryName, int $perPage = 100, int $page = 1, string $search = ''): array - { - $branches = $this->fetchAllBranches($owner, $repositoryName); - - if (!empty($search)) { - $branches = \array_values(\array_filter($branches, fn ($branch) => \str_contains($branch, $search))); - } - - return \array_slice($branches, ($page - 1) * $perPage, $perPage); - } - - /** - * Fetches every branch name for a repository, unpaginated. - * - * @return array - */ - private function fetchAllBranches(string $owner, string $repositoryName): array + public function listBranches(string $owner, string $repositoryName): array { $url = "/repos/{$owner}/{$repositoryName}/branches"; From 9e079babfc027d9e9694b0038bd74b53b036df8e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 10 Jul 2026 13:55:28 +0530 Subject: [PATCH 09/14] refactor: replace validateWebhookEvent with verifySignature Moves the default HMAC-SHA256 comparison into Adapter.php so adapters only override it when their scheme genuinely differs (GitHub's prefixed digest, GitLab's plain secret-token compare). Throws SignatureVerificationException instead of returning a bool, so callers don't need to re-derive what a false result means. --- src/VCS/Adapter.php | 22 ++++++++++--- src/VCS/Adapter/Git/GitHub.php | 16 ++++----- src/VCS/Adapter/Git/GitLab.php | 13 ++++++-- src/VCS/Adapter/Git/Gitea.php | 13 -------- src/VCS/Adapter/Git/Gogs.php | 1 - .../SignatureVerificationException.php | 7 ++++ tests/VCS/Adapter/GitHubTest.php | 9 +++-- tests/VCS/Adapter/GitLabTest.php | 31 ++++++++--------- tests/VCS/Adapter/GiteaTest.php | 33 ++++++++++--------- tests/VCS/Base.php | 17 ++++++++++ 10 files changed, 97 insertions(+), 65 deletions(-) create mode 100644 src/VCS/Exception/SignatureVerificationException.php diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 8ec6ec0d..6f90d642 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -3,6 +3,7 @@ namespace Utopia\VCS; use Exception; +use Utopia\VCS\Exception\SignatureVerificationException; abstract class Adapter { @@ -194,14 +195,25 @@ 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 + * Verifies 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 + * @throws SignatureVerificationException if the signature does not match */ - abstract public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool; + public function verifySignature(string $payload, string $signature, string $signatureKey): void + { + $expected = \hash_hmac('sha256', $payload, $signatureKey); + + if (!\hash_equals($expected, $signature)) { + throw new SignatureVerificationException('Webhook signature verification failed.'); + } + } /** * Parses webhook event payload @@ -221,8 +233,8 @@ 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. + * instead — check verifySignature()'s implementation per adapter before + * assuming uniform HMAC comparison. */ abstract public function getSignatureHeaderName(): string; diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index d85b01d7..54c676a1 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -8,6 +8,7 @@ use Utopia\VCS\Adapter\Git; use Utopia\VCS\Exception\FileNotFound; use Utopia\VCS\Exception\RepositoryNotFound; +use Utopia\VCS\Exception\SignatureVerificationException; class GitHub extends Git { @@ -1373,16 +1374,15 @@ public function getEvent(string $event, string $payload): array /** - * Validate webhook event - * - * @param string $payload Raw body of HTTP request - * @param string $signature Signature provided by GitHub in header - * @param string $signatureKey Webhook secret configured on GitHub - * @return bool + * @throws SignatureVerificationException if the signature does not match */ - public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool + public function verifySignature(string $payload, string $signature, string $signatureKey): void { - return $signature === ('sha256=' . hash_hmac('sha256', $payload, $signatureKey)); + $expected = 'sha256=' . hash_hmac('sha256', $payload, $signatureKey); + + if (!hash_equals($expected, $signature)) { + throw new SignatureVerificationException('Webhook signature verification failed.'); + } } 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 34d132f8..d62b8079 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -6,6 +6,7 @@ use Utopia\Cache\Cache; use Utopia\VCS\Adapter\Git; use Utopia\VCS\Exception\RepositoryNotFound; +use Utopia\VCS\Exception\SignatureVerificationException; class GitLab extends Git { @@ -1040,9 +1041,17 @@ public function getEvent(string $event, string $payload): array } } - public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool + /** + * GitLab doesn't sign the payload -- it just echoes back the configured + * secret token verbatim, so there's no HMAC to compute here. + * + * @throws SignatureVerificationException if the token does not match + */ + public function verifySignature(string $payload, string $signature, string $signatureKey): void { - return hash_equals($signatureKey, $signature); + if (!hash_equals($signatureKey, $signature)) { + throw new SignatureVerificationException('Webhook signature verification failed.'); + } } public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 80bdda20..d810b6ba 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -1230,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 34d3dc26..0a666b7d 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -34,7 +34,6 @@ public function getSignatureHeaderName(): string public function getBranchUrl(string $owner, string $repositoryName, string $branch): string { - // Gogs' web UI omits the "branch" path segment Gitea uses; /src/branch/{branch} 404s here. return $this->getRepositoryUrl($owner, $repositoryName) . "/src/{$branch}"; } diff --git a/src/VCS/Exception/SignatureVerificationException.php b/src/VCS/Exception/SignatureVerificationException.php new file mode 100644 index 00000000..0502eccb --- /dev/null +++ b/src/VCS/Exception/SignatureVerificationException.php @@ -0,0 +1,7 @@ +assertSame('1234', $result['installationId']); } - public function testValidateWebhookEvent(): void + public function testVerifySignature(): 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)); + $this->vcsAdapter->verifySignature($payload, $signature, $secret); + + $this->expectException(SignatureVerificationException::class); + $this->vcsAdapter->verifySignature($payload, 'sha256=wrongsig', $secret); } public function testCreateRepository(): void diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 42322e11..04d3e7a4 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -8,6 +8,7 @@ use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git; use Utopia\VCS\Adapter\Git\GitLab; +use Utopia\VCS\Exception\SignatureVerificationException; class GitLabTest extends Base { @@ -613,18 +614,15 @@ public function testGetLatestCommitWithInvalidBranch(): void } } - public function testValidateWebhookEvent(): void + public function testVerifySignature(): void { $secret = 'my-secret-token'; $payload = '{"object_kind":"push"}'; - // Valid token — should return true - $result = $this->vcsAdapter->validateWebhookEvent($payload, $secret, $secret); - $this->assertTrue($result); + $this->vcsAdapter->verifySignature($payload, $secret, $secret); - // Invalid token — should return false - $result = $this->vcsAdapter->validateWebhookEvent($payload, 'wrong-token', $secret); - $this->assertFalse($result); + $this->expectException(SignatureVerificationException::class); + $this->vcsAdapter->verifySignature($payload, 'wrong-token', $secret); } public function testWebhookPushEvent(): void @@ -1358,22 +1356,21 @@ public function testGetEventPushMatchesCheckoutSha(): void $this->assertSame('http://example.com/commit/def456', $result['commitUrl']); } - public function testValidateWebhookEventUsesPlainToken(): void + public function testVerifySignatureUsesPlainToken(): void { $secret = 'my-secret-token'; $payload = '{"object_kind":"push"}'; - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $secret, $secret) - ); + $this->vcsAdapter->verifySignature($payload, $secret, $secret); $hmacSignature = hash_hmac('sha256', $payload, $secret); - $this->assertFalse( - $this->vcsAdapter->validateWebhookEvent($payload, $hmacSignature, $secret) - ); + try { + $this->vcsAdapter->verifySignature($payload, $hmacSignature, $secret); + $this->fail('Expected SignatureVerificationException'); + } catch (SignatureVerificationException) { + } - $this->assertFalse( - $this->vcsAdapter->validateWebhookEvent($payload, 'wrong-token', $secret) - ); + $this->expectException(SignatureVerificationException::class); + $this->vcsAdapter->verifySignature($payload, 'wrong-token', $secret); } } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 2a9dbfd0..4a02af2f 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -8,6 +8,7 @@ use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git; use Utopia\VCS\Adapter\Git\Gitea; +use Utopia\VCS\Exception\SignatureVerificationException; class GiteaTest extends Base { @@ -1085,26 +1086,24 @@ public function testGetEventPullRequestExternal(): void $this->assertTrue($result['external']); } - public function testValidateWebhookEvent(): void + public function testVerifySignature(): 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); + $this->vcsAdapter->verifySignature($payload, $validSignature, $secret); + $this->addToAssertionCount(1); } - public function testValidateWebhookEventInvalid(): void + public function testVerifySignatureInvalid(): void { $payload = 'test payload content'; $secret = 'my-webhook-secret'; $invalidSignature = 'wrong-signature'; - $result = $this->vcsAdapter->validateWebhookEvent($payload, $invalidSignature, $secret); - - $this->assertFalse($result); + $this->expectException(SignatureVerificationException::class); + $this->vcsAdapter->verifySignature($payload, $invalidSignature, $secret); } public function testGetEventInvalidPayload(): void @@ -1645,10 +1644,11 @@ public function testWebhookPushEvent(): void $signature = $headers[$this->webhookSignatureHeader] ?? ''; $this->assertNotEmpty($signature, 'Missing ' . $this->webhookSignatureHeader . ' header'); - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), - 'Webhook signature validation failed' - ); + try { + $this->vcsAdapter->verifySignature($payload, $signature, $secret); + } catch (SignatureVerificationException $e) { + $this->fail('Webhook signature validation failed: ' . $e->getMessage()); + } $event = $this->vcsAdapter->getEvent('push', $payload); $this->assertIsArray($event); @@ -1704,10 +1704,11 @@ public function testWebhookPullRequestEvent(): void $signature = $headers[$this->webhookSignatureHeader] ?? ''; $this->assertNotEmpty($signature, 'Missing ' . $this->webhookSignatureHeader . ' header'); - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), - 'Webhook signature validation failed' - ); + try { + $this->vcsAdapter->verifySignature($payload, $signature, $secret); + } catch (SignatureVerificationException $e) { + $this->fail('Webhook signature validation failed: ' . $e->getMessage()); + } $event = $this->vcsAdapter->getEvent('pull_request', $payload); $this->assertIsArray($event); diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 5aadff0b..b25b986b 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -89,6 +89,23 @@ 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 testRequiresRepositoryWebhook(): void + { + $this->assertIsBool($this->vcsAdapter->requiresRepositoryWebhook()); + } + public function testGetPullRequestFromBranch(): void { $result = $this->vcsAdapter->getPullRequestFromBranch('vermakhushboo', 'basic-js-crud', 'test'); From 27fc470e16e379ac6904e06f48f3a2cbebea9c54 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 10 Jul 2026 15:02:00 +0530 Subject: [PATCH 10/14] revert: restore validateWebhookEvent name and bool contract Per review feedback -- the default HMAC-SHA256 implementation moved to Adapter.php doesn't need a new method name or exception-throwing contract; that was an unnecessary breaking change to the public API. Keeps validateWebhookEvent's existing signature, just makes it concrete with per-adapter overrides where the scheme differs. Also renames requiresRepositoryWebhook to hasPerRepositoryWebhooks -- the concept is the provider's own webhook delivery model (per-repo vs platform-wide), not something specific to how any one consumer uses it, so it shouldn't be phrased as a consumer's requirement. --- src/VCS/Adapter.php | 25 +++++++------- src/VCS/Adapter/Git/GitHub.php | 16 +++++---- src/VCS/Adapter/Git/GitLab.php | 11 ++----- src/VCS/Adapter/Git/Gitea.php | 2 +- .../SignatureVerificationException.php | 7 ---- tests/VCS/Adapter/GitHubTest.php | 9 ++--- tests/VCS/Adapter/GitLabTest.php | 31 +++++++++-------- tests/VCS/Adapter/GiteaTest.php | 33 +++++++++---------- tests/VCS/Base.php | 2 +- 9 files changed, 62 insertions(+), 74 deletions(-) delete mode 100644 src/VCS/Exception/SignatureVerificationException.php diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 6f90d642..312551e6 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -3,7 +3,6 @@ namespace Utopia\VCS; use Exception; -use Utopia\VCS\Exception\SignatureVerificationException; abstract class Adapter { @@ -195,7 +194,7 @@ 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; /** - * Verifies a webhook payload signature. + * 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 @@ -204,15 +203,13 @@ abstract public function generateCloneCommand(string $owner, string $repositoryN * @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 - * @throws SignatureVerificationException if the signature does not match + * @return bool */ - public function verifySignature(string $payload, string $signature, string $signatureKey): void + public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool { $expected = \hash_hmac('sha256', $payload, $signatureKey); - if (!\hash_equals($expected, $signature)) { - throw new SignatureVerificationException('Webhook signature verification failed.'); - } + return \hash_equals($expected, $signature); } /** @@ -233,17 +230,19 @@ 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 verifySignature()'s implementation per adapter before - * assuming uniform HMAC comparison. + * instead — check validateWebhookEvent()'s implementation per adapter + * before assuming uniform HMAC comparison. */ abstract public function getSignatureHeaderName(): string; /** - * Whether this provider needs an explicit webhook created per repository - * to receive push/pull_request events. False for providers that deliver - * events platform-wide once installed (e.g. a GitHub App). + * Whether this provider's webhook delivery is scoped per repository -- + * true if push/pull_request events only arrive once a webhook is + * registered on each individual repository, false if events are + * delivered platform-wide once the integration itself is installed + * (e.g. a GitHub App). */ - abstract public function requiresRepositoryWebhook(): bool; + abstract public function hasPerRepositoryWebhooks(): bool; /** * Browser-facing URL for a repository's home page. diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 54c676a1..b70486ed 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -8,7 +8,6 @@ use Utopia\VCS\Adapter\Git; use Utopia\VCS\Exception\FileNotFound; use Utopia\VCS\Exception\RepositoryNotFound; -use Utopia\VCS\Exception\SignatureVerificationException; class GitHub extends Git { @@ -1206,7 +1205,7 @@ public function getSignatureHeaderName(): string return 'x-hub-signature-256'; } - public function requiresRepositoryWebhook(): bool + public function hasPerRepositoryWebhooks(): bool { return false; } @@ -1374,15 +1373,18 @@ public function getEvent(string $event, string $payload): array /** - * @throws SignatureVerificationException if the signature does not match + * Validate webhook event + * + * @param string $payload Raw body of HTTP request + * @param string $signature Signature provided by GitHub in header + * @param string $signatureKey Webhook secret configured on GitHub + * @return bool */ - public function verifySignature(string $payload, string $signature, string $signatureKey): void + public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool { $expected = 'sha256=' . hash_hmac('sha256', $payload, $signatureKey); - if (!hash_equals($expected, $signature)) { - throw new SignatureVerificationException('Webhook signature verification failed.'); - } + 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 d62b8079..216e690c 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -6,7 +6,6 @@ use Utopia\Cache\Cache; use Utopia\VCS\Adapter\Git; use Utopia\VCS\Exception\RepositoryNotFound; -use Utopia\VCS\Exception\SignatureVerificationException; class GitLab extends Git { @@ -49,7 +48,7 @@ public function getSignatureHeaderName(): string return 'x-gitlab-token'; } - public function requiresRepositoryWebhook(): bool + public function hasPerRepositoryWebhooks(): bool { return true; } @@ -1044,14 +1043,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. - * - * @throws SignatureVerificationException if the token does not match */ - public function verifySignature(string $payload, string $signature, string $signatureKey): void + public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool { - if (!hash_equals($signatureKey, $signature)) { - throw new SignatureVerificationException('Webhook signature verification failed.'); - } + return hash_equals($signatureKey, $signature); } public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index d810b6ba..1ea3d904 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -691,7 +691,7 @@ public function getSignatureHeaderName(): string return 'x-gitea-signature'; } - public function requiresRepositoryWebhook(): bool + public function hasPerRepositoryWebhooks(): bool { return true; } diff --git a/src/VCS/Exception/SignatureVerificationException.php b/src/VCS/Exception/SignatureVerificationException.php deleted file mode 100644 index 0502eccb..00000000 --- a/src/VCS/Exception/SignatureVerificationException.php +++ /dev/null @@ -1,7 +0,0 @@ -assertSame('1234', $result['installationId']); } - public function testVerifySignature(): void + public function testValidateWebhookEvent(): void { $payload = '{"action":"push"}'; $secret = 'my-webhook-secret'; $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); - $this->vcsAdapter->verifySignature($payload, $signature, $secret); - - $this->expectException(SignatureVerificationException::class); - $this->vcsAdapter->verifySignature($payload, 'sha256=wrongsig', $secret); + $this->assertTrue($this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret)); + $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, 'sha256=wrongsig', $secret)); } public function testCreateRepository(): void diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 04d3e7a4..42322e11 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -8,7 +8,6 @@ use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git; use Utopia\VCS\Adapter\Git\GitLab; -use Utopia\VCS\Exception\SignatureVerificationException; class GitLabTest extends Base { @@ -614,15 +613,18 @@ public function testGetLatestCommitWithInvalidBranch(): void } } - public function testVerifySignature(): void + public function testValidateWebhookEvent(): void { $secret = 'my-secret-token'; $payload = '{"object_kind":"push"}'; - $this->vcsAdapter->verifySignature($payload, $secret, $secret); + // Valid token — should return true + $result = $this->vcsAdapter->validateWebhookEvent($payload, $secret, $secret); + $this->assertTrue($result); - $this->expectException(SignatureVerificationException::class); - $this->vcsAdapter->verifySignature($payload, 'wrong-token', $secret); + // Invalid token — should return false + $result = $this->vcsAdapter->validateWebhookEvent($payload, 'wrong-token', $secret); + $this->assertFalse($result); } public function testWebhookPushEvent(): void @@ -1356,21 +1358,22 @@ public function testGetEventPushMatchesCheckoutSha(): void $this->assertSame('http://example.com/commit/def456', $result['commitUrl']); } - public function testVerifySignatureUsesPlainToken(): void + public function testValidateWebhookEventUsesPlainToken(): void { $secret = 'my-secret-token'; $payload = '{"object_kind":"push"}'; - $this->vcsAdapter->verifySignature($payload, $secret, $secret); + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $secret, $secret) + ); $hmacSignature = hash_hmac('sha256', $payload, $secret); - try { - $this->vcsAdapter->verifySignature($payload, $hmacSignature, $secret); - $this->fail('Expected SignatureVerificationException'); - } catch (SignatureVerificationException) { - } + $this->assertFalse( + $this->vcsAdapter->validateWebhookEvent($payload, $hmacSignature, $secret) + ); - $this->expectException(SignatureVerificationException::class); - $this->vcsAdapter->verifySignature($payload, 'wrong-token', $secret); + $this->assertFalse( + $this->vcsAdapter->validateWebhookEvent($payload, 'wrong-token', $secret) + ); } } diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 4a02af2f..2a9dbfd0 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -8,7 +8,6 @@ use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git; use Utopia\VCS\Adapter\Git\Gitea; -use Utopia\VCS\Exception\SignatureVerificationException; class GiteaTest extends Base { @@ -1086,24 +1085,26 @@ public function testGetEventPullRequestExternal(): void $this->assertTrue($result['external']); } - public function testVerifySignature(): void + public function testValidateWebhookEvent(): void { $payload = 'test payload content'; $secret = 'my-webhook-secret'; $validSignature = hash_hmac('sha256', $payload, $secret); - $this->vcsAdapter->verifySignature($payload, $validSignature, $secret); - $this->addToAssertionCount(1); + $result = $this->vcsAdapter->validateWebhookEvent($payload, $validSignature, $secret); + + $this->assertTrue($result); } - public function testVerifySignatureInvalid(): void + public function testValidateWebhookEventInvalid(): void { $payload = 'test payload content'; $secret = 'my-webhook-secret'; $invalidSignature = 'wrong-signature'; - $this->expectException(SignatureVerificationException::class); - $this->vcsAdapter->verifySignature($payload, $invalidSignature, $secret); + $result = $this->vcsAdapter->validateWebhookEvent($payload, $invalidSignature, $secret); + + $this->assertFalse($result); } public function testGetEventInvalidPayload(): void @@ -1644,11 +1645,10 @@ public function testWebhookPushEvent(): void $signature = $headers[$this->webhookSignatureHeader] ?? ''; $this->assertNotEmpty($signature, 'Missing ' . $this->webhookSignatureHeader . ' header'); - try { - $this->vcsAdapter->verifySignature($payload, $signature, $secret); - } catch (SignatureVerificationException $e) { - $this->fail('Webhook signature validation failed: ' . $e->getMessage()); - } + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), + 'Webhook signature validation failed' + ); $event = $this->vcsAdapter->getEvent('push', $payload); $this->assertIsArray($event); @@ -1704,11 +1704,10 @@ public function testWebhookPullRequestEvent(): void $signature = $headers[$this->webhookSignatureHeader] ?? ''; $this->assertNotEmpty($signature, 'Missing ' . $this->webhookSignatureHeader . ' header'); - try { - $this->vcsAdapter->verifySignature($payload, $signature, $secret); - } catch (SignatureVerificationException $e) { - $this->fail('Webhook signature validation failed: ' . $e->getMessage()); - } + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret), + 'Webhook signature validation failed' + ); $event = $this->vcsAdapter->getEvent('pull_request', $payload); $this->assertIsArray($event); diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index b25b986b..ee59c9c4 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -103,7 +103,7 @@ public function testGetSignatureHeaderName(): void public function testRequiresRepositoryWebhook(): void { - $this->assertIsBool($this->vcsAdapter->requiresRepositoryWebhook()); + $this->assertIsBool($this->vcsAdapter->hasPerRepositoryWebhooks()); } public function testGetPullRequestFromBranch(): void From 48094a79a3e445a08af5f2c887bc11abfae1c4b0 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 10 Jul 2026 15:21:03 +0530 Subject: [PATCH 11/14] refactor: rename hasPerRepositoryWebhooks to supportsRepositoryWebhooks Per review discussion -- this should be a pure capability statement about the provider (can createWebhook() be called on an individual repository), not a statement about whether it's needed. Consumers decide what to do with that fact themselves. --- src/VCS/Adapter.php | 12 ++++++------ src/VCS/Adapter/Git/GitHub.php | 2 +- src/VCS/Adapter/Git/GitLab.php | 2 +- src/VCS/Adapter/Git/Gitea.php | 2 +- tests/VCS/Base.php | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 312551e6..e5b80401 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -236,13 +236,13 @@ abstract public function getEventHeaderName(): string; abstract public function getSignatureHeaderName(): string; /** - * Whether this provider's webhook delivery is scoped per repository -- - * true if push/pull_request events only arrive once a webhook is - * registered on each individual repository, false if events are - * delivered platform-wide once the integration itself is installed - * (e.g. a GitHub App). + * Whether this provider supports registering a webhook on an individual + * repository (i.e. createWebhook() is implemented and will succeed). + * False for providers where webhook delivery is managed at the + * integration level instead (e.g. a GitHub App, where createWebhook() + * is not applicable). */ - abstract public function hasPerRepositoryWebhooks(): bool; + abstract public function supportsRepositoryWebhooks(): bool; /** * Browser-facing URL for a repository's home page. diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index b70486ed..7e7b16ca 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -1205,7 +1205,7 @@ public function getSignatureHeaderName(): string return 'x-hub-signature-256'; } - public function hasPerRepositoryWebhooks(): bool + public function supportsRepositoryWebhooks(): bool { return false; } diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 216e690c..b8130897 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -48,7 +48,7 @@ public function getSignatureHeaderName(): string return 'x-gitlab-token'; } - public function hasPerRepositoryWebhooks(): bool + public function supportsRepositoryWebhooks(): bool { return true; } diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 1ea3d904..0ece9f19 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -691,7 +691,7 @@ public function getSignatureHeaderName(): string return 'x-gitea-signature'; } - public function hasPerRepositoryWebhooks(): bool + public function supportsRepositoryWebhooks(): bool { return true; } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index ee59c9c4..378e3570 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -103,7 +103,7 @@ public function testGetSignatureHeaderName(): void public function testRequiresRepositoryWebhook(): void { - $this->assertIsBool($this->vcsAdapter->hasPerRepositoryWebhooks()); + $this->assertIsBool($this->vcsAdapter->supportsRepositoryWebhooks()); } public function testGetPullRequestFromBranch(): void From 0441da94d4b653b95d8eaeabe8facb5a2782988b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 10 Jul 2026 15:50:34 +0530 Subject: [PATCH 12/14] refactor: replace supportsRepositoryWebhooks with getSupportedWebhookScopes Per review -- a single boolean conflated 'what the provider platform can technically do' with 'what this adapter implements'. An array of scopes (installation, repository) lets an adapter report more than one, and lets consumers prefer installation-level delivery when both are available, without the library making that preference for them. GitHub reports [installation] only, matching what's actually implemented (createWebhook() still isn't). Gitea/GitLab/Gogs/Forgejo report [repository], their only delivery mechanism. --- src/VCS/Adapter.php | 23 +++++++++++++++++------ src/VCS/Adapter/Git/GitHub.php | 4 ++-- src/VCS/Adapter/Git/GitLab.php | 4 ++-- src/VCS/Adapter/Git/Gitea.php | 4 ++-- tests/VCS/Base.php | 6 ++++-- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index e5b80401..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; @@ -236,13 +239,21 @@ abstract public function getEventHeaderName(): string; abstract public function getSignatureHeaderName(): string; /** - * Whether this provider supports registering a webhook on an individual - * repository (i.e. createWebhook() is implemented and will succeed). - * False for providers where webhook delivery is managed at the - * integration level instead (e.g. a GitHub App, where createWebhook() - * is not applicable). + * 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 supportsRepositoryWebhooks(): bool; + abstract public function getSupportedWebhookScopes(): array; /** * Browser-facing URL for a repository's home page. diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 7e7b16ca..f0bc43d3 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -1205,9 +1205,9 @@ public function getSignatureHeaderName(): string return 'x-hub-signature-256'; } - public function supportsRepositoryWebhooks(): bool + public function getSupportedWebhookScopes(): array { - return false; + return [self::WEBHOOK_SCOPE_INSTALLATION]; } public function getRepositoryUrl(string $owner, string $repositoryName): string diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index b8130897..51b20f49 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -48,9 +48,9 @@ public function getSignatureHeaderName(): string return 'x-gitlab-token'; } - public function supportsRepositoryWebhooks(): bool + public function getSupportedWebhookScopes(): array { - return true; + return [self::WEBHOOK_SCOPE_REPOSITORY]; } public function getRepositoryUrl(string $owner, string $repositoryName): string diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 0ece9f19..194d4ac2 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -691,9 +691,9 @@ public function getSignatureHeaderName(): string return 'x-gitea-signature'; } - public function supportsRepositoryWebhooks(): bool + public function getSupportedWebhookScopes(): array { - return true; + return [self::WEBHOOK_SCOPE_REPOSITORY]; } public function getRepositoryUrl(string $owner, string $repositoryName): string diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 378e3570..74439db3 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -101,9 +101,11 @@ public function testGetSignatureHeaderName(): void $this->assertNotEmpty($this->vcsAdapter->getSignatureHeaderName()); } - public function testRequiresRepositoryWebhook(): void + public function testGetSupportedWebhookScopes(): void { - $this->assertIsBool($this->vcsAdapter->supportsRepositoryWebhooks()); + $scopes = $this->vcsAdapter->getSupportedWebhookScopes(); + $this->assertIsArray($scopes); + $this->assertNotEmpty($scopes); } public function testGetPullRequestFromBranch(): void From 3044bca222095c44d1f110d8bc6f513ee081dd89 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 10 Jul 2026 17:36:13 +0530 Subject: [PATCH 13/14] feat: implement createWebhook() for GitHub, report repository scope Per review -- Appwrite won't use this (it prefers installation-scope delivery for GitHub), but the library shouldn't claim a capability it doesn't actually implement. Requires the GitHub App to have the 'Repository webhooks' permission granted; existing installations without it will need to accept the updated permission set. --- src/VCS/Adapter/Git/GitHub.php | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index f0bc43d3..c4d3313f 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -121,12 +121,32 @@ 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); + } + + return (int) ($response['body']['id'] ?? 0); } /** @@ -1207,7 +1227,7 @@ public function getSignatureHeaderName(): string public function getSupportedWebhookScopes(): array { - return [self::WEBHOOK_SCOPE_INSTALLATION]; + return [self::WEBHOOK_SCOPE_INSTALLATION, self::WEBHOOK_SCOPE_REPOSITORY]; } public function getRepositoryUrl(string $owner, string $repositoryName): string From f785974cb61238675a1269f9f262f77245c6d634 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 10 Jul 2026 17:46:02 +0530 Subject: [PATCH 14/14] fix: throw instead of silently returning 0 from createWebhook A 2xx response missing 'id' would previously return 0, which callers persisting the webhook ID for later deletion would silently store as valid. Now surfaces the anomaly immediately. --- src/VCS/Adapter/Git/GitHub.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index c4d3313f..ae8fa5b9 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -146,7 +146,12 @@ public function createWebhook(string $owner, string $repositoryName, string $url throw new Exception("Failed to create webhook: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); } - return (int) ($response['body']['id'] ?? 0); + $id = $response['body']['id'] ?? null; + if ($id === null) { + throw new Exception('Webhook created but response did not include an id'); + } + + return (int) $id; } /**