Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions src/VCS/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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<string>
*/
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
*
Expand Down
10 changes: 10 additions & 0 deletions src/VCS/Adapter/Git/Forgejo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Comment thread
Meldiron marked this conversation as resolved.
}
70 changes: 66 additions & 4 deletions src/VCS/Adapter/Git/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 57 additions & 14 deletions src/VCS/Adapter/Git/Gitea.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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}";
Expand Down Expand Up @@ -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
*
Expand Down
17 changes: 16 additions & 1 deletion src/VCS/Adapter/Git/Gogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading