Skip to content

Commit 7b835da

Browse files
authored
Merge pull request #74 from jaysomani/feat/gitea-webhook-support
feat: implement Gitea webhook support
2 parents d18eda2 + 92cbe19 commit 7b835da

File tree

9 files changed

+666
-34
lines changed

9 files changed

+666
-34
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"require": {
3030
"php": ">=8.0",
3131
"adhocore/jwt": "^1.1",
32-
"utopia-php/cache": "1.0.*"
32+
"utopia-php/cache": "1.0.*",
33+
"utopia-php/fetch": "1.0.*"
3334
},
3435
"require-dev": {
3536
"phpunit/phpunit": "^9.4",

composer.lock

Lines changed: 44 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docker-compose.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ services:
1212
- TESTS_GITHUB_APP_IDENTIFIER
1313
- TESTS_GITHUB_INSTALLATION_ID
1414
- TESTS_GITEA_URL=http://gitea:3000
15+
- TESTS_GITEA_REQUEST_CATCHER_URL=http://request-catcher:5000
1516
depends_on:
1617
gitea:
1718
condition: service_healthy
1819
gitea-bootstrap:
1920
condition: service_completed_successfully
21+
request-catcher:
22+
condition: service_started
2023

2124
gitea:
2225
image: gitea/gitea:1.21.5
@@ -25,6 +28,10 @@ services:
2528
- USER_GID=1000
2629
- GITEA__database__DB_TYPE=sqlite3
2730
- GITEA__security__INSTALL_LOCK=true
31+
- GITEA__webhook__ALLOWED_HOST_LIST=*
32+
- GITEA__webhook__SKIP_TLS_VERIFY=true
33+
- GITEA__webhook__DELIVER_TIMEOUT=10
34+
- GITEA__server__LOCAL_ROOT_URL=http://gitea:3000/
2835
ports:
2936
- "3000:3000"
3037
volumes:
@@ -54,6 +61,9 @@ services:
5461
TOKEN=$$(su git -c \"gitea admin user generate-access-token --username $$GITEA_ADMIN_USERNAME --token-name $$GITEA_ADMIN_USERNAME-token --scopes all --raw\") &&
5562
echo $$TOKEN > /data/gitea/token.txt
5663
"
57-
64+
request-catcher:
65+
image: appwrite/requestcatcher:1.1.0
66+
ports:
67+
- "5000:5000"
5868
volumes:
5969
gitea-data:

src/VCS/Adapter/Git.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,17 @@ abstract public function createBranch(string $owner, string $repositoryName, str
7070
* @return array<mixed> Created PR details
7171
*/
7272
abstract public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array;
73+
74+
/**
75+
* Create a webhook on a repository
76+
*
77+
* @param string $owner Owner of the repository
78+
* @param string $repositoryName Name of the repository
79+
* @param string $url Webhook URL to send events to
80+
* @param string $secret Webhook secret for signature validation
81+
* @param array<string> $events Events to trigger the webhook
82+
* @return int Webhook ID
83+
*/
84+
abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int;
85+
7386
}

src/VCS/Adapter/Git/GitHub.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ public function createPullRequest(string $owner, string $repositoryName, string
109109
throw new Exception('Not implemented');
110110
}
111111

112+
/**
113+
* Create a webhook on a repository
114+
*
115+
* Note: Not applicable for GitHub - webhooks are managed via GitHub Apps
116+
*/
117+
public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int
118+
{
119+
throw new Exception('Not applicable for GitHub - webhooks are managed via GitHub Apps');
120+
}
121+
112122
/**
113123
* Create a file in a repository
114124
*

src/VCS/Adapter/Git/Gitea.php

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,43 @@ public function createPullRequest(string $owner, string $repositoryName, string
473473
return $responseBody;
474474
}
475475

476+
/**
477+
* Create a webhook on a repository
478+
*
479+
* @param string $owner Owner of the repository
480+
* @param string $repositoryName Name of the repository
481+
* @param string $url Webhook URL to send events to
482+
* @param string $secret Webhook secret for signature validation
483+
* @param array<string> $events Events to trigger the webhook
484+
* @return int Webhook ID
485+
*/
486+
public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int
487+
{
488+
$response = $this->call(
489+
self::METHOD_POST,
490+
"/repos/{$owner}/{$repositoryName}/hooks",
491+
['Authorization' => "token $this->accessToken"],
492+
[
493+
'type' => 'gitea',
494+
'active' => true,
495+
'events' => $events,
496+
'config' => [
497+
'url' => $url,
498+
'content_type' => 'json',
499+
'secret' => $secret,
500+
],
501+
]
502+
);
503+
504+
$responseHeaders = $response['headers'] ?? [];
505+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
506+
if ($responseHeadersStatusCode >= 400) {
507+
throw new Exception("Failed to create webhook: HTTP {$responseHeadersStatusCode}");
508+
}
509+
510+
return (int) ($response['body']['id'] ?? 0);
511+
}
512+
476513
public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string
477514
{
478515
$url = "/repos/{$owner}/{$repositoryName}/issues/{$pullRequestNumber}/comments";
@@ -739,13 +776,142 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri
739776
throw new Exception("Not implemented yet");
740777
}
741778

779+
/**
780+
* Parses webhook event payload
781+
*
782+
* @param string $event Type of event: push, pull_request, etc
783+
* @param string $payload The webhook payload received from Gitea
784+
* @return array<mixed> Parsed payload as an array
785+
*/
742786
public function getEvent(string $event, string $payload): array
743787
{
744-
throw new Exception("Not implemented yet");
788+
$payload = json_decode($payload, true);
789+
790+
if ($payload === null || !is_array($payload)) {
791+
throw new Exception("Invalid payload.");
792+
}
793+
794+
switch ($event) {
795+
case 'push':
796+
$payloadRepository = $payload['repository'] ?? [];
797+
$payloadRepositoryOwner = $payloadRepository['owner'] ?? [];
798+
$payloadSender = $payload['sender'] ?? [];
799+
$payloadHeadCommit = $payload['head_commit'] ?? [];
800+
$payloadHeadCommitAuthor = $payloadHeadCommit['author'] ?? [];
801+
802+
$branchCreated = $payload['created'] ?? false;
803+
$branchDeleted = $payload['deleted'] ?? false;
804+
$repositoryId = strval($payloadRepository['id'] ?? '');
805+
$repositoryName = $payloadRepository['name'] ?? '';
806+
$branch = str_replace('refs/heads/', '', $payload['ref'] ?? '');
807+
$repositoryUrl = $payloadRepository['html_url'] ?? '';
808+
$branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . "/src/branch/" . $branch : '';
809+
$commitHash = $payload['after'] ?? '';
810+
$owner = $payloadRepositoryOwner['login'] ?? '';
811+
$authorUrl = $payloadSender['html_url'] ?? '';
812+
$authorAvatarUrl = $payloadSender['avatar_url'] ?? '';
813+
$headCommitAuthorName = $payloadHeadCommitAuthor['name'] ?? '';
814+
$headCommitAuthorEmail = $payloadHeadCommitAuthor['email'] ?? '';
815+
$headCommitMessage = $payloadHeadCommit['message'] ?? '';
816+
$headCommitUrl = $payloadHeadCommit['url'] ?? '';
817+
818+
$affectedFiles = [];
819+
foreach (($payload['commits'] ?? []) as $commit) {
820+
foreach (($commit['added'] ?? []) as $added) {
821+
$affectedFiles[$added] = true;
822+
}
823+
824+
foreach (($commit['removed'] ?? []) as $removed) {
825+
$affectedFiles[$removed] = true;
826+
}
827+
828+
foreach (($commit['modified'] ?? []) as $modified) {
829+
$affectedFiles[$modified] = true;
830+
}
831+
}
832+
833+
return [
834+
'branchCreated' => $branchCreated,
835+
'branchDeleted' => $branchDeleted,
836+
'branch' => $branch,
837+
'branchUrl' => $branchUrl,
838+
'repositoryId' => $repositoryId,
839+
'repositoryName' => $repositoryName,
840+
'repositoryUrl' => $repositoryUrl,
841+
'installationId' => '', // Gitea doesn't have installations
842+
'commitHash' => $commitHash,
843+
'owner' => $owner,
844+
'authorUrl' => $authorUrl,
845+
'authorAvatarUrl' => $authorAvatarUrl,
846+
'headCommitAuthorName' => $headCommitAuthorName,
847+
'headCommitAuthorEmail' => $headCommitAuthorEmail,
848+
'headCommitMessage' => $headCommitMessage,
849+
'headCommitUrl' => $headCommitUrl,
850+
'external' => false,
851+
'pullRequestNumber' => '',
852+
'action' => '',
853+
'affectedFiles' => \array_keys($affectedFiles),
854+
];
855+
856+
case 'pull_request':
857+
$payloadRepository = $payload['repository'] ?? [];
858+
$payloadRepositoryOwner = $payloadRepository['owner'] ?? [];
859+
$payloadSender = $payload['sender'] ?? [];
860+
$payloadPullRequest = $payload['pull_request'] ?? [];
861+
$payloadPullRequestHead = $payloadPullRequest['head'] ?? [];
862+
$payloadPullRequestHeadRepo = $payloadPullRequestHead['repo'] ?? [];
863+
$payloadPullRequestUser = $payloadPullRequest['user'] ?? [];
864+
$payloadPullRequestBase = $payloadPullRequest['base'] ?? [];
865+
866+
$repositoryId = strval($payloadRepository['id'] ?? '');
867+
$branch = $payloadPullRequestHead['ref'] ?? '';
868+
$repositoryName = $payloadRepository['name'] ?? '';
869+
$repositoryUrl = $payloadRepository['html_url'] ?? '';
870+
$branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . "/src/branch/" . $branch : '';
871+
$pullRequestNumber = $payload['number'] ?? '';
872+
$action = $payload['action'] ?? '';
873+
$owner = $payloadRepositoryOwner['login'] ?? '';
874+
$authorUrl = $payloadSender['html_url'] ?? '';
875+
$authorAvatarUrl = $payloadPullRequestUser['avatar_url'] ?? '';
876+
$commitHash = $payloadPullRequestHead['sha'] ?? '';
877+
$headCommitUrl = $repositoryUrl ? $repositoryUrl . "/commit/" . $commitHash : '';
878+
879+
// Check if PR is from a fork (external)
880+
$headRepoFullName = $payloadPullRequestHeadRepo['full_name'] ?? '';
881+
$baseRepoFullName = $payloadRepository['full_name'] ?? '';
882+
$external = !empty($headRepoFullName) && !empty($baseRepoFullName) && $headRepoFullName !== $baseRepoFullName;
883+
884+
return [
885+
'branch' => $branch,
886+
'branchUrl' => $branchUrl,
887+
'repositoryId' => $repositoryId,
888+
'repositoryName' => $repositoryName,
889+
'repositoryUrl' => $repositoryUrl,
890+
'installationId' => '', // Gitea doesn't have installations
891+
'commitHash' => $commitHash,
892+
'owner' => $owner,
893+
'authorUrl' => $authorUrl,
894+
'authorAvatarUrl' => $authorAvatarUrl,
895+
'headCommitUrl' => $headCommitUrl,
896+
'external' => $external,
897+
'pullRequestNumber' => $pullRequestNumber,
898+
'action' => $action,
899+
];
900+
}
901+
902+
return [];
745903
}
746904

905+
/**
906+
* Validate webhook event
907+
*
908+
* @param string $payload Raw body of HTTP request
909+
* @param string $signature Signature provided by Gitea in X-Gitea-Signature header
910+
* @param string $signatureKey Webhook secret configured on Gitea
911+
* @return bool
912+
*/
747913
public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool
748914
{
749-
throw new Exception("Not implemented yet");
915+
return hash_equals($signature, hash_hmac('sha256', $payload, $signatureKey));
750916
}
751917
}

0 commit comments

Comments
 (0)