From ebadbad84723448013560ec49ad591da76089e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 10 Apr 2026 17:01:12 +0200 Subject: [PATCH] Add gitea support --- .env | 6 + app/config/collections/platform.php | 4 +- app/init/constants.php | 3 + composer.json | 2 +- composer.lock | 63 +++-- docker-compose.yml | 83 +++++- src/Appwrite/Auth/OAuth2/Gitea.php | 213 ++++++++++++++ .../Platform/Modules/Compute/Base.php | 58 ++-- .../Modules/Console/Http/Variables/Get.php | 8 +- .../Http/Deployments/Template/Create.php | 13 +- .../Functions/Http/Deployments/Vcs/Create.php | 11 +- .../Functions/Http/Functions/Create.php | 17 +- .../Functions/Http/Functions/Update.php | 17 +- .../Modules/Functions/Workers/Builds.php | 75 +++-- .../Http/Deployments/Template/Create.php | 12 +- .../Sites/Http/Deployments/Vcs/Create.php | 11 +- .../Modules/Sites/Http/Sites/Create.php | 13 +- .../Modules/Sites/Http/Sites/Update.php | 18 +- .../Http/GitHub/Authorize/External/Update.php | 38 +-- .../Modules/VCS/Http/GitHub/Deployment.php | 10 +- .../Modules/VCS/Http/GitHub/Events/Create.php | 2 +- .../Modules/VCS/Http/Gitea/Authorize/Get.php | 97 +++++++ .../Modules/VCS/Http/Gitea/Callback/Get.php | 180 ++++++++++++ .../Modules/VCS/Http/Gitea/Events/Create.php | 223 +++++++++++++++ .../Repositories/Branches/XList.php | 24 +- .../Repositories/Contents/Get.php | 25 +- .../Installations/Repositories/Create.php | 64 +++-- .../Repositories/Detections/Create.php | 29 +- .../Http/Installations/Repositories/Get.php | 31 +- .../Http/Installations/Repositories/XList.php | 34 +-- .../Platform/Modules/VCS/Services/Http.php | 12 +- src/Appwrite/Vcs/VcsFactory.php | 267 ++++++++++++++++++ 32 files changed, 1414 insertions(+), 249 deletions(-) create mode 100644 src/Appwrite/Auth/OAuth2/Gitea.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/Gitea/Authorize/Get.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/Gitea/Callback/Get.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/Gitea/Events/Create.php create mode 100644 src/Appwrite/Vcs/VcsFactory.php diff --git a/.env b/.env index 9abfa756e1..40975ef448 100644 --- a/.env +++ b/.env @@ -133,6 +133,12 @@ _APP_VCS_GITHUB_APP_ID= _APP_VCS_GITHUB_CLIENT_ID= _APP_VCS_GITHUB_CLIENT_SECRET= _APP_VCS_GITHUB_WEBHOOK_SECRET= +_APP_VCS_GITEA_ENDPOINT=http://gitea:3000 +_APP_VCS_GITEA_BROWSER_ENDPOINT=http://localhost:9510 +_APP_VCS_GITEA_CLIENT_ID=appwrite-gitea-client-id +_APP_VCS_GITEA_CLIENT_SECRET=appwrite-gitea-client-secret +_APP_VCS_GITEA_WEBHOOK_SECRET=gitea-webhook-secret +_APP_VCS_WEBHOOK_URL=http://appwrite:80 _APP_MIGRATIONS_FIREBASE_CLIENT_ID= _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET= _APP_ASSISTANT_OPENAI_API_KEY= diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 6195c11724..9676274010 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -1546,7 +1546,7 @@ $platformCollections = [ '$id' => ID::custom('personalAccessToken'), 'type' => Database::VAR_STRING, 'format' => '', - 'size' => 256, + 'size' => 2048, 'signed' => true, 'required' => false, 'default' => null, @@ -1568,7 +1568,7 @@ $platformCollections = [ '$id' => ID::custom('personalRefreshToken'), 'type' => Database::VAR_STRING, 'format' => '', - 'size' => 256, + 'size' => 2048, 'signed' => true, 'required' => false, 'default' => null, diff --git a/app/init/constants.php b/app/init/constants.php index 3b907572ab..c3aa909f2c 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -101,6 +101,9 @@ const APP_SDK_PLATFORM_STATIC = 'static'; const APP_VCS_GITHUB_USERNAME = 'Appwrite'; const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io'; const APP_VCS_GITHUB_URL = 'https://github.com/TeamAppwrite'; +const APP_VCS_COMMIT_USERNAME = 'Appwrite'; +const APP_VCS_COMMIT_EMAIL = 'team@appwrite.io'; +const APP_VCS_PROVIDERS = ['github', 'gitea']; const APP_BRANDED_EMAIL_BASE_TEMPLATE = 'email-base-styled'; /** diff --git a/composer.json b/composer.json index 4ad1ae6120..ed88b460fb 100644 --- a/composer.json +++ b/composer.json @@ -84,7 +84,7 @@ "utopia-php/storage": "1.0.*", "utopia-php/system": "0.10.*", "utopia-php/telemetry": "0.2.*", - "utopia-php/vcs": "3.*", + "utopia-php/vcs": "dev-fix-gitea-branch-list as 3.99.99", "utopia-php/websocket": "1.0.*", "matomo/device-detector": "6.4.*", "dragonmantank/cron-expression": "3.4.*", diff --git a/composer.lock b/composer.lock index 164b3a036f..a6019fe342 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4fb974e9843f6104e40396e7cad4a833", + "content-hash": "cf36a6ac7c5041c9ec493333e6e05c52", "packages": [ { "name": "adhocore/jwt", @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.50", + "version": "3.0.51", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-04-10T01:33:53+00:00" }, { "name": "psr/clock", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.19", + "version": "5.3.20", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" + "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", - "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", + "url": "https://api.github.com/repos/utopia-php/database/zipball/fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", + "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.19" + "source": "https://github.com/utopia-php/database/tree/5.3.20" }, - "time": "2026-03-31T15:52:08+00:00" + "time": "2026-04-10T08:27:41+00:00" }, { "name": "utopia-php/detector", @@ -5225,16 +5225,16 @@ }, { "name": "utopia-php/vcs", - "version": "3.1.0", + "version": "dev-fix-gitea-branch-list", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af" + "reference": "ff7feacff8cd3e23de77dd360ffb931d984b8c03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/ff7feacff8cd3e23de77dd360ffb931d984b8c03", + "reference": "ff7feacff8cd3e23de77dd360ffb931d984b8c03", "shasum": "" }, "require": { @@ -5268,9 +5268,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/3.1.0" + "source": "https://github.com/utopia-php/vcs/tree/fix-gitea-branch-list" }, - "time": "2026-03-24T08:49:14+00:00" + "time": "2026-04-10T14:43:29+00:00" }, { "name": "utopia-php/websocket", @@ -5448,16 +5448,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.7", + "version": "1.17.8", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e" + "reference": "b7109e13cec89ed56ad80111973b12646e4eb5f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/291471d04c3f0e7b9fcc46668a6255a4c0f2947e", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/b7109e13cec89ed56ad80111973b12646e4eb5f7", + "reference": "b7109e13cec89ed56ad80111973b12646e4eb5f7", "shasum": "" }, "require": { @@ -5493,9 +5493,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.7" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.8" }, - "time": "2026-04-08T08:51:05+00:00" + "time": "2026-04-09T05:08:21+00:00" }, { "name": "brianium/paratest", @@ -8426,9 +8426,18 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/vcs", + "version": "dev-fix-gitea-branch-list", + "alias": "3.99.99", + "alias_normalized": "3.99.99.0" + } + ], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "utopia-php/vcs": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/docker-compose.yml b/docker-compose.yml index aa2bfdd16a..a43d00626c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -232,6 +232,12 @@ services: - _APP_VCS_GITHUB_WEBHOOK_SECRET - _APP_VCS_GITHUB_CLIENT_SECRET - _APP_VCS_GITHUB_CLIENT_ID + - _APP_VCS_GITEA_ENDPOINT + - _APP_VCS_GITEA_BROWSER_ENDPOINT + - _APP_VCS_GITEA_CLIENT_ID + - _APP_VCS_GITEA_CLIENT_SECRET + - _APP_VCS_GITEA_WEBHOOK_SECRET + - _APP_VCS_WEBHOOK_URL - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET - _APP_ASSISTANT_OPENAI_API_KEY @@ -546,6 +552,11 @@ services: - _APP_VCS_GITHUB_APP_NAME - _APP_VCS_GITHUB_PRIVATE_KEY - _APP_VCS_GITHUB_APP_ID + - _APP_VCS_GITEA_ENDPOINT + - _APP_VCS_GITEA_BROWSER_ENDPOINT + - _APP_VCS_GITEA_CLIENT_ID + - _APP_VCS_GITEA_CLIENT_SECRET + - _APP_VCS_GITEA_WEBHOOK_SECRET - _APP_FUNCTIONS_TIMEOUT - _APP_SITES_TIMEOUT - _APP_COMPUTE_BUILD_TIMEOUT @@ -1447,6 +1458,75 @@ services: networks: - appwrite + gitea: + image: gitea/gitea:1.21.5 + container_name: appwrite-gitea + <<: *x-logging + ports: + - "9510:3000" + volumes: + - appwrite-gitea:/data + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__security__INSTALL_LOCK=true + - GITEA__webhook__ALLOWED_HOST_LIST=* + - GITEA__webhook__SKIP_TLS_VERIFY=true + - GITEA__webhook__DELIVER_TIMEOUT=10 + - GITEA__server__ROOT_URL=http://gitea:3000/ + - GITEA__server__LOCAL_ROOT_URL=http://gitea:3000/ + networks: + - appwrite + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + + gitea-setup: + image: gitea/gitea:1.21.5 + container_name: appwrite-gitea-setup + <<: *x-logging + depends_on: + gitea: + condition: service_healthy + volumes: + - appwrite-gitea:/data + environment: + - GITEA_ADMIN_USERNAME=appwrite + - GITEA_ADMIN_PASSWORD=password + - GITEA_ADMIN_EMAIL=admin@appwrite.local + - GITEA_OAUTH2_CLIENT_ID=${_APP_VCS_GITEA_CLIENT_ID:-appwrite-gitea-client-id} + - GITEA_OAUTH2_CLIENT_SECRET=${_APP_VCS_GITEA_CLIENT_SECRET:-appwrite-gitea-client-secret} + - GITEA_OAUTH2_REDIRECT_URIS=["http://localhost/v1/vcs/gitea/callback","http://localhost:9501/v1/vcs/gitea/callback","http://localhost:80/v1/vcs/gitea/callback"] + entrypoint: > + sh -c " + echo 'Setting up Gitea...' && + su git -c 'gitea admin user create --username $$GITEA_ADMIN_USERNAME --password $$GITEA_ADMIN_PASSWORD --email $$GITEA_ADMIN_EMAIL --admin --must-change-password=false' 2>/dev/null || true && + TOKEN=$$(su git -c 'gitea admin user generate-access-token --username $$GITEA_ADMIN_USERNAME --token-name appwrite-token --scopes all --raw' 2>/dev/null) && + echo $$TOKEN > /data/gitea/token.txt && + echo 'Creating OAuth2 application...' && + apk add --no-cache apache2-utils sqlite > /dev/null 2>&1 && + SECRET_HASH=$$(htpasswd -nbBC 10 '' \"$$GITEA_OAUTH2_CLIENT_SECRET\" | cut -d: -f2) && + USER_ID=$$(sqlite3 /data/gitea/gitea.db \"SELECT id FROM user WHERE lower_name='$$GITEA_ADMIN_USERNAME' LIMIT 1;\") && + TIMESTAMP=$$(date +%s) && + sqlite3 /data/gitea/gitea.db \"DELETE FROM oauth2_application WHERE name='Appwrite';\" && + sqlite3 /data/gitea/gitea.db \"INSERT INTO oauth2_application (uid, name, client_id, client_secret, confidential_client, redirect_uris, created_unix, updated_unix) VALUES ($$USER_ID, 'Appwrite', '$$GITEA_OAUTH2_CLIENT_ID', '$$SECRET_HASH', 1, '$$GITEA_OAUTH2_REDIRECT_URIS', $$TIMESTAMP, $$TIMESTAMP);\" && + echo '' && + echo '========================================' && + echo 'Gitea setup complete!' && + echo '========================================' && + echo \"Gitea URL: http://localhost:9510\" && + echo \"Admin user: $$GITEA_ADMIN_USERNAME\" && + echo \"Admin password: $$GITEA_ADMIN_PASSWORD\" && + echo \"OAuth2 Client ID: $$GITEA_OAUTH2_CLIENT_ID\" && + echo '========================================' + " + networks: + - appwrite + # Dev tools (adminer, redis-insight, mongo-express, graphql-explorer) # are defined in docker-compose.override.yml @@ -1477,4 +1557,5 @@ volumes: appwrite-sites: appwrite-builds: appwrite-config: - appwrite-models: \ No newline at end of file + appwrite-models: + appwrite-gitea: \ No newline at end of file diff --git a/src/Appwrite/Auth/OAuth2/Gitea.php b/src/Appwrite/Auth/OAuth2/Gitea.php new file mode 100644 index 0000000000..9e463354db --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Gitea.php @@ -0,0 +1,213 @@ +endpoint = rtrim($endpoint, '/'); + parent::__construct($appId, $appSecret, $callback, $state, $scopes); + } + + /** + * @return string + */ + public function getName(): string + { + return 'gitea'; + } + + /** + * @return string + */ + public function getLoginURL(): string + { + return $this->endpoint . '/login/oauth/authorize?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'response_type' => 'code', + 'state' => \json_encode($this->state), + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $response = $this->request( + 'POST', + $this->endpoint . '/login/oauth/access_token', + ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + \http_build_query([ + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'redirect_uri' => $this->callback, + 'grant_type' => 'authorization_code', + 'code' => $code, + ]) + ); + + $this->tokens = \json_decode($response, true) ?? []; + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $response = $this->request( + 'POST', + $this->endpoint . '/login/oauth/access_token', + ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + \http_build_query([ + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]) + ); + + $this->tokens = \json_decode($response, true) ?? []; + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return \strval($user['id'] ?? ''); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['email'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return true; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['full_name'] ?? $user['login'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserSlug(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['login'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $response = $this->request('GET', $this->endpoint . '/api/v1/user', ['Authorization: token ' . \urlencode($accessToken)]); + $this->user = \json_decode($response, true) ?? []; + } + + return $this->user; + } + + /** + * @param string $accessToken + * @param string $repositoryName + * @param bool $private + * + * @return array + */ + public function createRepository(string $accessToken, string $repositoryName, bool $private): array + { + $response = $this->request( + 'POST', + $this->endpoint . '/api/v1/user/repos', + ['Authorization: token ' . \urlencode($accessToken), 'Content-Type: application/json'], + \json_encode([ + 'name' => $repositoryName, + 'private' => $private, + ]) + ); + + return \json_decode($response, true) ?? []; + } +} diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index f388e46f83..e1d710f8d1 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -17,9 +17,11 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Http\Adapter\Swoole\Request; use Utopia\System\System; -use Utopia\VCS\Adapter\Git\GitHub; +use Utopia\VCS\Adapter\Git; use Utopia\VCS\Exception\RepositoryNotFound; class Base extends Action @@ -57,18 +59,16 @@ class Base extends Action return $allowedSpecifications[0] ?? APP_COMPUTE_SPECIFICATION_DEFAULT; } - public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, string $referenceType = 'branch', string $reference = ''): Document + public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, Git $vcs, bool $activate, string $referenceType = 'branch', string $reference = ''): Document { $deploymentId = ID::unique(); $entrypoint = $function->getAttribute('entrypoint', ''); + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId', ''); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $function->getAttribute('providerRepositoryId', ''); + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $vcs->getRepositoryName($providerRepositoryId) ?? ''; if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -77,33 +77,30 @@ class Base extends Action } $commitDetails = []; - $branchUrl = ""; - $providerBranch = ""; + $branchUrl = ''; + $providerBranch = ''; // TODO: Support tag in future if ($referenceType === 'branch') { $providerBranch = empty($reference) ? $function->getAttribute('providerBranch', 'main') : $reference; - $branchUrl = "https://github.com/$owner/$repositoryName/tree/$providerBranch"; + $branchUrl = VcsFactory::getBranchUrl($provider, $owner, $repositoryName, $providerBranch); try { - $commitDetails = $github->getLatestCommit($owner, $repositoryName, $providerBranch); + $commitDetails = $vcs->getLatestCommit($owner, $repositoryName, $providerBranch); } catch (\Throwable $error) { // Ignore; deployment can continue } } elseif ($referenceType === 'commit') { try { - $commitDetails = $github->getCommit($owner, $repositoryName, $reference); + $commitDetails = $vcs->getCommit($owner, $repositoryName, $reference); } catch (\Throwable $error) { // Ignore; deployment can continue } } else { - // Fallback till we have tag support here - // Goal is to set providerBranch, so build worker knows what to clone as base - // Without this, clone command would be cloning empty branch, and failing $providerBranch = $function->getAttribute('providerBranch', 'main'); - $branchUrl = "https://github.com/$owner/$repositoryName/tree/$providerBranch"; + $branchUrl = VcsFactory::getBranchUrl($provider, $owner, $repositoryName, $providerBranch); } - $repositoryUrl = "https://github.com/$owner/$repositoryName"; + $repositoryUrl = VcsFactory::getRepoUrl($provider, $owner, $repositoryName); $deployment = $dbForProject->createDocument('deployments', new Document([ '$id' => $deploymentId, @@ -159,17 +156,15 @@ class Base extends Action return $deployment; } - public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document + public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, Git $vcs, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document { $deploymentId = ID::unique(); + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId', ''); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $site->getAttribute('providerRepositoryId', ''); + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $vcs->getRepositoryName($providerRepositoryId) ?? ''; if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -178,33 +173,30 @@ class Base extends Action } $commitDetails = []; - $branchUrl = ""; - $providerBranch = ""; + $branchUrl = ''; + $providerBranch = ''; // TODO: Support tag in future if ($referenceType === 'branch') { $providerBranch = empty($reference) ? $site->getAttribute('providerBranch', 'main') : $reference; - $branchUrl = "https://github.com/$owner/$repositoryName/tree/$providerBranch"; + $branchUrl = VcsFactory::getBranchUrl($provider, $owner, $repositoryName, $providerBranch); try { - $commitDetails = $github->getLatestCommit($owner, $repositoryName, $providerBranch); + $commitDetails = $vcs->getLatestCommit($owner, $repositoryName, $providerBranch); } catch (\Throwable $error) { // Ignore; deployment can continue } } elseif ($referenceType === 'commit') { try { - $commitDetails = $github->getCommit($owner, $repositoryName, $reference); + $commitDetails = $vcs->getCommit($owner, $repositoryName, $reference); } catch (\Throwable $error) { // Ignore; deployment can continue } } else { - // Fallback till we have tag support here - // Goal is to set providerBranch, so build worker knows what to clone as base - // Without this, clone command would be cloning empty branch, and failing $providerBranch = $site->getAttribute('providerBranch', 'main'); - $branchUrl = "https://github.com/$owner/$repositoryName/tree/$providerBranch"; + $branchUrl = VcsFactory::getBranchUrl($provider, $owner, $repositoryName, $providerBranch); } - $repositoryUrl = "https://github.com/$owner/$repositoryName"; + $repositoryUrl = VcsFactory::getRepoUrl($provider, $owner, $repositoryName); $commands = []; if (!empty($site->getAttribute('installCommand', ''))) { diff --git a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php index 8368b272f1..be9dbf9c33 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php @@ -7,6 +7,7 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Appwrite\Vcs\VcsFactory; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Domains\Domain; @@ -66,11 +67,7 @@ class Get extends Action $isDomainEnabled = $isAAAAValid || $isAValid || $isCNAMEValid; - $isVcsEnabled = !empty(System::getEnv('_APP_VCS_GITHUB_APP_NAME', '')) - && !empty(System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY', '')) - && !empty(System::getEnv('_APP_VCS_GITHUB_APP_ID', '')) - && !empty(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', '')) - && !empty(System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', '')); + $isVcsEnabled = !empty(VcsFactory::getConfiguredProviders()); $isAssistantEnabled = !empty(System::getEnv('_APP_ASSISTANT_OPENAI_API_KEY', '')); @@ -86,6 +83,7 @@ class Get extends Action '_APP_COMPUTE_SIZE_LIMIT' => +System::getEnv('_APP_COMPUTE_SIZE_LIMIT'), '_APP_USAGE_STATS' => System::getEnv('_APP_USAGE_STATS'), '_APP_VCS_ENABLED' => $isVcsEnabled, + '_APP_VCS_PROVIDERS' => VcsFactory::getConfiguredProviders(), '_APP_DOMAIN_ENABLED' => $isDomainEnabled, '_APP_ASSISTANT_ENABLED' => $isAssistantEnabled, '_APP_DOMAIN_SITES' => $platform['sitesDomain'], diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php index 53af82e701..0c6f71cd5f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php @@ -20,10 +20,11 @@ use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Base { @@ -77,7 +78,7 @@ class Create extends Base ->inject('queueForEvents') ->inject('project') ->inject('queueForBuilds') - ->inject('gitHub') + ->inject('cache') ->inject('authorization') ->callback($this->action(...)); } @@ -97,7 +98,7 @@ class Create extends Base Event $queueForEvents, Document $project, Build $queueForBuilds, - GitHub $github, + Cache $cache, Authorization $authorization ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -106,8 +107,8 @@ class Create extends Base throw new Exception(Exception::FUNCTION_NOT_FOUND); } + // Templates are always from GitHub $branchUrl = "https://github.com/$owner/$repository/blob/$reference"; - $repositoryUrl = "https://github.com/$owner/$repository"; $template = new Document([ @@ -120,6 +121,8 @@ class Create extends Base if (!empty($function->getAttribute('providerRepositoryId'))) { $installation = $dbForPlatform->getDocument('installations', $function->getAttribute('installationId')); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $deployment = $this->redeployVcsFunction( request: $request, @@ -129,7 +132,7 @@ class Create extends Base dbForProject: $dbForProject, queueForBuilds: $queueForBuilds, template: $template, - github: $github, + vcs: $vcs, activate: $activate, referenceType: $type, reference: $reference diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php index 587c09beba..3cae8bcac1 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php @@ -16,10 +16,11 @@ use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Base { @@ -71,7 +72,7 @@ class Create extends Base ->inject('project') ->inject('queueForEvents') ->inject('queueForBuilds') - ->inject('gitHub') + ->inject('cache') ->callback($this->action(...)); } @@ -87,7 +88,7 @@ class Create extends Base Document $project, Event $queueForEvents, Build $queueForBuilds, - GitHub $github, + Cache $cache, ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -98,6 +99,8 @@ class Create extends Base $template = new Document(); $installation = $dbForPlatform->getDocument('installations', $function->getAttribute('installationId')); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $deployment = $this->redeployVcsFunction( request: $request, @@ -107,7 +110,7 @@ class Create extends Base dbForProject: $dbForProject, queueForBuilds: $queueForBuilds, template: $template, - github: $github, + vcs: $vcs, activate: $activate, reference: $reference, referenceType: $type diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 8d4ad5d403..4c3714a993 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -37,8 +37,9 @@ use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Base { @@ -121,7 +122,7 @@ class Create extends Base ->inject('queueForFunctions') ->inject('dbForPlatform') ->inject('request') - ->inject('gitHub') + ->inject('cache') ->inject('authorization') ->inject('platform') ->callback($this->action(...)); @@ -163,7 +164,7 @@ class Create extends Base Func $queueForFunctions, Database $dbForPlatform, Request $request, - GitHub $github, + Cache $cache, Authorization $authorization, array $platform ) { @@ -287,6 +288,12 @@ class Create extends Base 'providerPullRequestIds' => [] ])); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); + $owner = VcsFactory::getOwnerName($vcs, $provider, $installation->getAttribute('providerInstallationId', ''), $providerRepositoryId); + $repositoryName = $vcs->getRepositoryName($providerRepositoryId); + VcsFactory::createRepositoryWebhook($vcs, $provider, $owner, $repositoryName); + $function->setAttribute('repositoryId', $repository->getId()); $function->setAttribute('repositoryInternalId', $repository->getSequence()); } @@ -320,6 +327,8 @@ class Create extends Base $template = new Document(); $installation = $dbForPlatform->getDocument('installations', $function->getAttribute('installationId')); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $deployment = $this->redeployVcsFunction( request: $request, function: $function, @@ -328,7 +337,7 @@ class Create extends Base dbForProject: $dbForProject, queueForBuilds: $queueForBuilds, template: $template, - github: $github, + vcs: $vcs, activate: true, reference: $providerBranch, referenceType: 'branch' diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 71fc99a30e..cd8405ae7b 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -33,7 +33,8 @@ use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; class Update extends Base { @@ -107,7 +108,7 @@ class Update extends Base ->inject('queueForEvents') ->inject('queueForBuilds') ->inject('dbForPlatform') - ->inject('gitHub') + ->inject('cache') ->inject('executor') ->inject('authorization') ->callback($this->action(...)); @@ -141,7 +142,7 @@ class Update extends Base Event $queueForEvents, Build $queueForBuilds, Database $dbForPlatform, - GitHub $github, + Cache $cache, Executor $executor, Authorization $authorization ) { @@ -219,6 +220,12 @@ class Update extends Base 'providerPullRequestIds' => [], ])); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); + $owner = VcsFactory::getOwnerName($vcs, $provider, $installation->getAttribute('providerInstallationId', ''), $providerRepositoryId); + $repositoryName = $vcs->getRepositoryName($providerRepositoryId); + VcsFactory::createRepositoryWebhook($vcs, $provider, $owner, $repositoryName); + $repositoryId = $repository->getId(); $repositoryInternalId = $repository->getSequence(); } @@ -285,7 +292,9 @@ class Update extends Base // Redeploy logic if (!$isConnected && !empty($providerRepositoryId)) { - $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); + $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $vcs, true); } // Inform scheduler if function is still active diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index c6c4a0b38c..2a2d349ab3 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -36,8 +36,9 @@ use Utopia\Platform\Action; use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\Storage\Device\Local; +use Appwrite\Vcs\VcsFactory; use Utopia\System\System; -use Utopia\VCS\Adapter\Git\GitHub; +use Utopia\VCS\Adapter; class Builds extends Action { @@ -121,7 +122,6 @@ class Builds extends Action case BUILD_TYPE_DEPLOYMENT: case BUILD_TYPE_RETRY: Console::info('Creating build for deployment: ' . $deployment->getId()); - $github = new GitHub($cache); $this->buildDeployment( $deviceForFunctions, $deviceForSites, @@ -135,7 +135,7 @@ class Builds extends Action $publisherForUsage, $dbForPlatform, $dbForProject, - $github, + $cache, $project, $resource, $deployment, @@ -170,7 +170,7 @@ class Builds extends Action UsagePublisher $publisherForUsage, Database $dbForPlatform, Database $dbForProject, - GitHub $github, + Cache $cache, Document $project, Document $resource, Document $deployment, @@ -270,13 +270,15 @@ class Builds extends Action $owner = ''; $repositoryName = ''; + Console::log('[DEBUG build] deploymentId=' . $deploymentId . ' installationId=' . $installationId . ' providerRepositoryId=' . $providerRepositoryId . ' isVcsEnabled=' . ($isVcsEnabled ? 'true' : 'false') . ' source=' . $source); + + $provider = 'github'; + $vcs = null; if ($isVcsEnabled) { $installation = $dbForPlatform->getDocument('installations', $installationId); - $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); + $provider = $installation->getAttribute('provider', 'github'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); } try { @@ -299,7 +301,9 @@ class Builds extends Action // Clone template repo $tmpTemplateDirectory = '/tmp/builds/' . $deploymentId . '-template'; - $gitCloneCommandForTemplate = $github->generateCloneCommand($templateOwnerName, $templateRepositoryName, $templateReferenceValue, $templateReferenceType, $tmpTemplateDirectory, $templateRootDirectory); + // Templates are public GitHub repos — no authentication needed + $templateGitHub = VcsFactory::getAdapter('github', $cache); + $gitCloneCommandForTemplate = $templateGitHub->generateCloneCommand($templateOwnerName, $templateRepositoryName, $templateReferenceValue, $templateReferenceType, $tmpTemplateDirectory, $templateRootDirectory); $exit = Console::execute($gitCloneCommandForTemplate, '', $stdout, $stderr); @@ -357,8 +361,9 @@ class Builds extends Action $rootDirectory = \ltrim($rootDirectory, '.'); $rootDirectory = \ltrim($rootDirectory, '/'); - $owner = $github->getOwnerName($providerInstallationId); - $repositoryName = $github->getRepositoryName($providerRepositoryId); + $providerInstallationId = $installation->getAttribute('providerInstallationId', ''); + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId); + $repositoryName = $vcs->getRepositoryName($providerRepositoryId); $cloneOwner = $deployment->getAttribute('providerRepositoryOwner', $owner); $cloneRepository = $deployment->getAttribute('providerRepositoryName', $repositoryName); @@ -367,13 +372,15 @@ class Builds extends Action $commitHash = $deployment->getAttribute('providerCommitHash', ''); $cloneVersion = $branchName; - $cloneType = GitHub::CLONE_TYPE_BRANCH; + $cloneType = Adapter::CLONE_TYPE_BRANCH; if (! empty($commitHash)) { $cloneVersion = $commitHash; - $cloneType = GitHub::CLONE_TYPE_COMMIT; + $cloneType = Adapter::CLONE_TYPE_COMMIT; } - $gitCloneCommand = $github->generateCloneCommand($cloneOwner, $cloneRepository, $cloneVersion, $cloneType, $tmpDirectory, $rootDirectory); + Console::log('[DEBUG build-vcs] provider=' . $provider . ' cloneOwner=' . $cloneOwner . ' cloneRepo=' . $cloneRepository . ' cloneVersion=' . $cloneVersion . ' cloneType=' . $cloneType . ' branch=' . $branchName . ' commitHash=' . $commitHash); + + $gitCloneCommand = $vcs->generateCloneCommand($cloneOwner, $cloneRepository, $cloneVersion, $cloneType, $tmpDirectory, $rootDirectory); $stdout = ''; $stderr = ''; @@ -387,12 +394,20 @@ class Builds extends Action $exit = Console::execute($gitCloneCommand, '', $stdout, $stderr); + Console::log('[DEBUG build-vcs] clone exit=' . $exit . ' stdout=' . $stdout . ' stderr=' . $stderr); + if ($exit !== 0) { throw new \Exception('Unable to clone code repository: ' . $stderr); } Console::log('Git repository cloned'); + // List files to verify clone content + $lsOut = ''; + $lsErr = ''; + Console::execute('ls -la ' . \escapeshellarg($tmpDirectory), '', $lsOut, $lsErr); + Console::log('[DEBUG build-vcs] files in tmpDirectory: ' . $lsOut); + // Local refactoring for function folder with spaces if (str_contains($rootDirectory, ' ')) { $rootDirectoryWithoutSpaces = str_replace(' ', '', $rootDirectory); @@ -421,7 +436,9 @@ class Builds extends Action // Clone template repo $tmpTemplateDirectory = '/tmp/builds/' . $deploymentId . '/template'; - $gitCloneCommandForTemplate = $github->generateCloneCommand($templateOwnerName, $templateRepositoryName, $templateReferenceValue, $templateReferenceType, $tmpTemplateDirectory, $templateRootDirectory); + // Templates are public GitHub repos — no authentication needed + $templateGitHub = VcsFactory::getAdapter('github', $cache); + $gitCloneCommandForTemplate = $templateGitHub->generateCloneCommand($templateOwnerName, $templateRepositoryName, $templateReferenceValue, $templateReferenceType, $tmpTemplateDirectory, $templateRootDirectory); $exit = Console::execute($gitCloneCommandForTemplate, '', $stdout, $stderr); if ($exit !== 0) { @@ -435,8 +452,8 @@ class Builds extends Action // Merge template into user repo Console::execute('rsync -av --exclude \'.git\' ' . \escapeshellarg($tmpTemplateDirectory . '/' . $templateRootDirectory . '/') . ' ' . \escapeshellarg($tmpDirectory . '/' . $rootDirectory), '', $stdout, $stderr); - // Commit and push - $exit = Console::execute('git config --global user.email ' . \escapeshellarg(APP_VCS_GITHUB_EMAIL) . ' && git config --global user.name ' . \escapeshellarg(APP_VCS_GITHUB_USERNAME) . ' && cd ' . \escapeshellarg($tmpDirectory) . ' && git checkout -b ' . \escapeshellarg($branchName) . ' && git add . && git commit -m "Create ' . \escapeshellarg($resource->getAttribute('name', '')) . ' function" && git push origin ' . \escapeshellarg($branchName), '', $stdout, $stderr); + // Disable sparse-checkout so template files can be staged, then commit and push + $exit = Console::execute('git config --global user.email ' . \escapeshellarg(APP_VCS_COMMIT_EMAIL) . ' && git config --global user.name ' . \escapeshellarg(APP_VCS_COMMIT_USERNAME) . ' && cd ' . \escapeshellarg($tmpDirectory) . ' && git sparse-checkout disable && git checkout -b ' . \escapeshellarg($branchName) . ' && git add . && git commit -m "Create ' . \escapeshellarg($resource->getAttribute('name', '')) . ' function" && git push origin ' . \escapeshellarg($branchName), '', $stdout, $stderr); if ($exit !== 0) { throw new \Exception('Unable to push code repository: ' . $stderr); @@ -451,10 +468,10 @@ class Builds extends Action $providerCommitHash = \trim($stdout); $deployment->setAttribute('providerCommitHash', $providerCommitHash); - $deployment->setAttribute('providerCommitAuthorUrl', APP_VCS_GITHUB_URL); - $deployment->setAttribute('providerCommitAuthor', APP_VCS_GITHUB_USERNAME); + $deployment->setAttribute('providerCommitAuthorUrl', VcsFactory::getProviderUrl($provider)); + $deployment->setAttribute('providerCommitAuthor', APP_VCS_COMMIT_USERNAME); $deployment->setAttribute('providerCommitMessage', "Create '" . $resource->getAttribute('name', '') . "' function"); - $deployment->setAttribute('providerCommitUrl', "https://github.com/$cloneOwner/$cloneRepository/commit/$providerCommitHash"); + $deployment->setAttribute('providerCommitUrl', VcsFactory::getCommitUrl($provider, $cloneOwner, $cloneRepository, $providerCommitHash)); $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([ 'providerCommitHash' => $deployment->getAttribute('providerCommitHash'), 'providerCommitAuthorUrl' => $deployment->getAttribute('providerCommitAuthorUrl'), @@ -521,7 +538,7 @@ class Builds extends Action Console::log('Git source uploaded'); - $this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); + $this->runGitAction('processing', $vcs, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } Console::log('Status marked as building'); @@ -541,7 +558,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('building', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); + $this->runGitAction('building', $vcs, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } $deploymentModel = new Deployment(); @@ -931,7 +948,7 @@ class Builds extends Action } if ($isVcsEnabled) { - $this->runGitAction('ready', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); + $this->runGitAction('ready', $vcs, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } /** Set auto deploy */ @@ -1183,7 +1200,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); + $this->runGitAction('failed', $vcs, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } } finally { $queueForRealtime @@ -1354,7 +1371,7 @@ class Builds extends Action */ protected function runGitAction( string $status, - GitHub $github, + ?Adapter\Git $vcs, string $providerCommitHash, string $owner, string $repositoryName, @@ -1369,7 +1386,7 @@ class Builds extends Action $deployment = new Document(); try { - if ($resource->getAttribute('providerSilentMode', false) === true) { + if ($resource->getAttribute('providerSilentMode', false) === true || $vcs === null) { return; } @@ -1408,7 +1425,7 @@ class Builds extends Action default => throw new \Exception('Invalid resource type') }; - $github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, $state, $message, $providerTargetUrl, $name); + $vcs->updateCommitStatus($repositoryName, $providerCommitHash, $owner, $state, $message, $providerTargetUrl, $name); } if (! empty($commentId)) { @@ -1453,9 +1470,9 @@ class Builds extends Action }; $comment = new Comment($platform); - $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); + $comment->parseComment($vcs->getComment($owner, $repositoryName, $commentId)); $comment->addBuild($project, $resource, $resourceType, $status, $deployment->getId(), ['type' => 'logs'], $previewUrl); - $github->updateComment($owner, $repositoryName, $commentId, $comment->generateComment()); + $vcs->updateComment($owner, $repositoryName, $commentId, $comment->generateComment()); } finally { $dbForPlatform->deleteDocument('vcsCommentLocks', $commentId); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php index f648c57a83..d39b9397e2 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -21,10 +21,11 @@ use Utopia\Http\Adapter\Swoole\Request; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Base { @@ -78,7 +79,7 @@ class Create extends Base ->inject('project') ->inject('queueForEvents') ->inject('queueForBuilds') - ->inject('gitHub') + ->inject('cache') ->inject('authorization') ->inject('platform') ->callback($this->action(...)); @@ -99,7 +100,7 @@ class Create extends Base Document $project, Event $queueForEvents, Build $queueForBuilds, - GitHub $github, + Cache $cache, Authorization $authorization, array $platform ) { @@ -109,6 +110,7 @@ class Create extends Base throw new Exception(Exception::SITE_NOT_FOUND); } + // Templates are always from GitHub $branchUrl = "https://github.com/$owner/$repository/blob/$reference"; $repositoryUrl = "https://github.com/$owner/$repository"; @@ -122,6 +124,8 @@ class Create extends Base if (!empty($site->getAttribute('providerRepositoryId'))) { $installation = $dbForPlatform->getDocument('installations', $site->getAttribute('installationId')); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $deployment = $this->redeployVcsSite( request: $request, @@ -132,7 +136,7 @@ class Create extends Base dbForPlatform: $dbForPlatform, queueForBuilds: $queueForBuilds, template: $template, - github: $github, + vcs: $vcs, activate: $activate, authorization: $authorization, platform: $platform diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php index 4351dd8dd9..cd2f901677 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php @@ -17,10 +17,11 @@ use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Request; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Base { @@ -72,7 +73,7 @@ class Create extends Base ->inject('project') ->inject('queueForEvents') ->inject('queueForBuilds') - ->inject('gitHub') + ->inject('cache') ->inject('authorization') ->inject('platform') ->callback($this->action(...)); @@ -90,7 +91,7 @@ class Create extends Base Document $project, Event $queueForEvents, Build $queueForBuilds, - GitHub $github, + Cache $cache, Authorization $authorization, array $platform ) { @@ -103,6 +104,8 @@ class Create extends Base $template = new Document(); $installation = $dbForPlatform->getDocument('installations', $site->getAttribute('installationId')); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $deployment = $this->redeployVcsSite( request: $request, @@ -113,7 +116,7 @@ class Create extends Base dbForPlatform: $dbForPlatform, queueForBuilds: $queueForBuilds, template: $template, - github: $github, + vcs: $vcs, activate: $activate, authorization: $authorization, reference: $reference, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index d01d0d8ca7..3acdb8ca36 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -19,6 +19,8 @@ use Utopia\Database\Helpers\ID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -96,6 +98,7 @@ class Create extends Base ->inject('project') ->inject('queueForEvents') ->inject('dbForPlatform') + ->inject('cache') ->callback($this->action(...)); } @@ -125,7 +128,8 @@ class Create extends Base Database $dbForProject, Document $project, Event $queueForEvents, - Database $dbForPlatform + Database $dbForPlatform, + Cache $cache ) { if (!empty($adapter)) { $configFramework = Config::getParam('frameworks')[$framework] ?? []; @@ -201,6 +205,13 @@ class Create extends Base 'providerPullRequestIds' => [] ]); $repository = $dbForPlatform->createDocument('repositories', $repository); + + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); + $owner = VcsFactory::getOwnerName($vcs, $provider, $installation->getAttribute('providerInstallationId', ''), $providerRepositoryId); + $repoName = $vcs->getRepositoryName($providerRepositoryId); + VcsFactory::createRepositoryWebhook($vcs, $provider, $owner, $repoName); + $site->setAttribute('repositoryId', $repository->getId()); $site->setAttribute('repositoryInternalId', $repository->getSequence()); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index dd9bedffb5..7c043750fc 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -26,7 +26,8 @@ use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; class Update extends Base { @@ -101,7 +102,7 @@ class Update extends Base ->inject('queueForEvents') ->inject('queueForBuilds') ->inject('dbForPlatform') - ->inject('gitHub') + ->inject('cache') ->inject('executor') ->callback($this->action(...)); } @@ -135,7 +136,7 @@ class Update extends Base Event $queueForEvents, Build $queueForBuilds, Database $dbForPlatform, - GitHub $github, + Cache $cache, Executor $executor ) { if (!empty($adapter)) { @@ -215,6 +216,13 @@ class Update extends Base 'providerPullRequestIds' => [] ]); $repository = $dbForPlatform->createDocument('repositories', $repository); + + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); + $owner = VcsFactory::getOwnerName($vcs, $provider, $installation->getAttribute('providerInstallationId', ''), $providerRepositoryId); + $repoName = $vcs->getRepositoryName($providerRepositoryId); + VcsFactory::createRepositoryWebhook($vcs, $provider, $owner, $repoName); + $repositoryId = $repository->getId(); $repositoryInternalId = $repository->getSequence(); } @@ -283,7 +291,9 @@ class Update extends Base // Redeploy logic if (!$isConnected && !empty($providerRepositoryId)) { - $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true); + $provider = $installation->getAttribute('provider', 'github'); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); + $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $vcs, true); } $queueForEvents->setParam('siteId', $site->getId()); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php index 8b320535e9..306d41ee5c 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php @@ -10,14 +10,14 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; use Utopia\Validator\Text; -use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\RepositoryNotFound; class Update extends Action @@ -34,7 +34,8 @@ class Update extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/vcs/github/installations/:installationId/repositories/:repositoryId') + ->setHttpPath('/v1/vcs/installations/:installationId/repositories/:repositoryId') + ->httpAlias('/v1/vcs/github/installations/:installationId/repositories/:repositoryId') ->desc('Update external deployment (authorize)') ->groups(['api', 'vcs']) ->label('scope', 'vcs.write') @@ -53,8 +54,8 @@ class Update extends Action )) ->param('installationId', '', new Text(256), 'Installation Id') ->param('repositoryId', '', new Text(256), 'VCS Repository Id') - ->param('providerPullRequestId', '', new Text(256), 'GitHub Pull Request Id') - ->inject('gitHub') + ->param('providerPullRequestId', '', new Text(256), 'Pull Request Id') + ->inject('cache') ->inject('response') ->inject('project') ->inject('dbForPlatform') @@ -69,7 +70,7 @@ class Update extends Action string $installationId, string $repositoryId, string $providerPullRequestId, - GitHub $github, + Cache $cache, Response $response, Document $project, Database $dbForPlatform, @@ -101,36 +102,35 @@ class Update extends Action $repository = $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['providerPullRequestIds' => $providerPullRequestIds]))); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $repositories = [$repository]; $providerRepositoryId = $repository->getAttribute('providerRepositoryId'); try { - $providerRepositoryName = $github->getRepositoryName($providerRepositoryId); + $providerRepositoryName = $vcs->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $owner = $github->getOwnerName($providerInstallationId); - $pullRequestResponse = $github->getPullRequest($owner, $providerRepositoryName, $providerPullRequestId); + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId); + $pullRequestResponse = $vcs->getPullRequest($owner, $providerRepositoryName, $providerPullRequestId); $providerRepositoryUrl = $pullRequestResponse['head']['repo']['html_url'] ?? ''; $providerRepositoryOwner = $pullRequestResponse['head']['repo']['owner']['login'] ?? ''; - $providerBranch = \explode(':', $pullRequestResponse['head']['label'])[1] ?? ''; + $providerBranch = \explode(':', $pullRequestResponse['head']['label'] ?? '')[1] ?? ($pullRequestResponse['head']['ref'] ?? ''); $providerBranchUrl = "$providerRepositoryUrl/tree/$providerBranch"; $providerCommitHash = $pullRequestResponse['head']['sha'] ?? ''; - $commitDetails = $github->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash); - $providerCommitMessage = $commitDetails["commitMessage"] ?? ''; - $providerCommitUrl = $commitDetails["commitUrl"] ?? ''; - $providerCommitAuthor = $commitDetails["commitAuthor"] ?? ''; - $providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? ''; + $commitDetails = $vcs->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash); + $providerCommitMessage = $commitDetails['commitMessage'] ?? ''; + $providerCommitUrl = $commitDetails['commitUrl'] ?? ''; + $providerCommitAuthor = $commitDetails['commitAuthor'] ?? ''; + $providerCommitAuthorUrl = $commitDetails['commitAuthorUrl'] ?? ''; - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($vcs, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 6e1db12c28..a0c1791a71 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -20,13 +20,13 @@ use Utopia\Database\Validator\Authorization; use Utopia\DSN\DSN; use Utopia\Span\Span; use Utopia\System\System; -use Utopia\VCS\Adapter\Git\GitHub; +use Utopia\VCS\Adapter\Git; use Utopia\VCS\Exception\RepositoryNotFound; trait Deployment { protected function createGitDeployments( - GitHub $github, + Git $github, string $providerInstallationId, array $repositories, string $providerBranch, @@ -107,7 +107,7 @@ trait Deployment $activate = true; } - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $providerRepositoryOwner; try { $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { @@ -290,7 +290,7 @@ trait Deployment } catch (RepositoryNotFound $e) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $owner = $github->getOwnerName($providerInstallationId); + $owner = $providerRepositoryOwner; $github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $authorizeUrl, $name); continue; } @@ -520,7 +520,7 @@ trait Deployment } catch (RepositoryNotFound $e) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $owner = $github->getOwnerName($providerInstallationId); + $owner = $providerRepositoryOwner; $providerTargetUrl = $protocol . '://' . $hostname . "/console/project-$region-$projectId/$resourceCollection/$resourceType-$resourceId"; $github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, 'pending', $message, $providerTargetUrl, $name); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php index e3dbcfa0e9..5f5734151b 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php @@ -163,7 +163,7 @@ class Create extends Action ])); // Create new deployment only on push (not committed by us) and not when branch is deleted - if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) { + if ($providerCommitAuthorEmail !== APP_VCS_COMMIT_EMAIL && !$providerBranchDeleted) { $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); } } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Authorize/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Authorize/Get.php new file mode 100644 index 0000000000..b8c63543bc --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Authorize/Get.php @@ -0,0 +1,97 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/gitea/authorize') + ->desc('Create Gitea installation') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.read') + ->label('error', APP_VIEWS_DIR . '/general/error.phtml') + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'createGiteaInstallation', + description: '/docs/references/vcs/create-gitea-installation.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_MOVED_PERMANENTLY, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::HTML, + type: MethodType::WEBAUTH, + hide: true, + )) + ->param('success', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a successful installation attempt.', true, ['redirectValidator']) + ->param('failure', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a failed installation attempt.', true, ['redirectValidator']) + ->inject('response') + ->inject('project') + ->inject('platform') + ->callback($this->action(...)); + } + + public function action( + string $success, + string $failure, + Response $response, + Document $project, + array $platform + ) { + $endpoint = VcsFactory::getEndpoint('gitea'); + $browserEndpoint = VcsFactory::getBrowserEndpoint('gitea'); + $clientId = VcsFactory::getClientId('gitea'); + + if (empty($endpoint) || empty($clientId)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Gitea VCS is not configured. Please configure _APP_VCS_GITEA_ENDPOINT and _APP_VCS_GITEA_CLIENT_ID environment variables.'); + } + + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $hostname = $platform['consoleHostname'] ?? ''; + + $state = \json_encode([ + 'projectId' => $project->getId(), + 'success' => $success, + 'failure' => $failure, + ]); + + $url = rtrim($browserEndpoint, '/') . '/login/oauth/authorize?' . \http_build_query([ + 'client_id' => $clientId, + 'redirect_uri' => $protocol . '://' . $hostname . '/v1/vcs/gitea/callback', + 'response_type' => 'code', + 'scope' => 'read:user write:repository read:organization', + 'state' => $state, + ]); + + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($url); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Callback/Get.php new file mode 100644 index 0000000000..6292a519b1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Callback/Get.php @@ -0,0 +1,180 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/gitea/callback') + ->desc('Get installation and authorization from Gitea') + ->groups(['api', 'vcs']) + ->label('scope', 'public') + ->label('error', APP_VIEWS_DIR . '/general/error.phtml') + ->param('code', '', new Text(2048, 0), 'OAuth2 code.', true) + ->param('state', '', new Text(2048), 'State containing project info.', true) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('platform') + ->callback($this->action(...)); + } + + public function action( + string $code, + string $state, + Document $project, + Response $response, + Database $dbForPlatform, + array $platform + ) { + if (empty($state)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Missing state parameter.'); + } + + $state = \json_decode($state, true); + $redirectFailure = $state['failure'] ?? ''; + $projectId = $state['projectId'] ?? ''; + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + $error = 'Project with the ID from state could not be found.'; + + if (!empty($redirectFailure)) { + $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); + return; + } + + throw new Exception(Exception::PROJECT_NOT_FOUND, $error); + } + + $region = $project->getAttribute('region', 'default'); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $hostname = $platform['consoleHostname'] ?? ''; + + $defaultState = [ + 'success' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations", + 'failure' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations", + ]; + + $state = \array_merge($defaultState, $state ?? []); + $redirectSuccess = $state['success'] ?? ''; + $redirectFailure = $state['failure'] ?? ''; + + if (empty($code)) { + $error = 'OAuth2 authorization code is missing.'; + + if (!empty($redirectFailure)) { + $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); + return; + } + + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); + } + + $endpoint = VcsFactory::getEndpoint('gitea'); + $clientId = VcsFactory::getClientId('gitea'); + $clientSecret = VcsFactory::getClientSecret('gitea'); + + $oauth2 = new OAuth2Gitea($clientId, $clientSecret, '', [], [], $endpoint); + + $accessToken = $oauth2->getAccessToken($code) ?? ''; + $refreshToken = $oauth2->getRefreshToken($code) ?? ''; + $accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code))); + + $owner = $oauth2->getUserSlug($accessToken) ?? ''; + + if (empty($owner)) { + $error = 'Failed to get user information from Gitea.'; + + if (!empty($redirectFailure)) { + $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); + return; + } + + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); + } + + $projectInternalId = $project->getSequence(); + + // Use the OAuth2 user ID as the providerInstallationId for Gitea + $providerInstallationId = $oauth2->getUserID($accessToken); + + $installation = $dbForPlatform->findOne('installations', [ + Query::equal('providerInstallationId', [$providerInstallationId]), + Query::equal('projectInternalId', [$projectInternalId]), + Query::equal('provider', ['gitea']), + ]); + + if ($installation->isEmpty()) { + $teamId = $project->getAttribute('teamId', ''); + + $installation = new Document([ + '$id' => ID::unique(), + '$permissions' => $this->getPermissions($teamId, $projectId), + 'providerInstallationId' => $providerInstallationId, + 'projectId' => $projectId, + 'projectInternalId' => $projectInternalId, + 'provider' => 'gitea', + 'organization' => $owner, + 'personal' => true, + 'personalRefreshToken' => $refreshToken, + 'personalAccessToken' => $accessToken, + 'personalAccessTokenExpiry' => $accessTokenExpiry, + ]); + + $installation = $dbForPlatform->createDocument('installations', $installation); + } else { + $installation = $dbForPlatform->updateDocument('installations', $installation->getId(), new Document([ + 'organization' => $owner, + 'personal' => true, + 'personalRefreshToken' => $refreshToken, + 'personalAccessToken' => $accessToken, + 'personalAccessTokenExpiry' => $accessTokenExpiry, + ])); + } + + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirectSuccess); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Events/Create.php new file mode 100644 index 0000000000..6ac62176b9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Gitea/Events/Create.php @@ -0,0 +1,223 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vcs/gitea/events') + ->desc('Create Gitea event') + ->groups(['api', 'vcs']) + ->label('scope', 'public') + ->inject('cache') + ->inject('request') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('getProjectDB') + ->inject('queueForBuilds') + ->inject('platform') + ->callback($this->action(...)); + } + + public function action( + Cache $cache, + Request $request, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + callable $getProjectDB, + Build $queueForBuilds, + array $platform + ) { + $event = $request->getHeader('x-gitea-event', ''); + Span::add('vcs.gitea.event.name', $event); + + $payload = $request->getRawPayload(); + $signature = $request->getHeader('x-gitea-signature', ''); + $secretKey = VcsFactory::getWebhookSecret('gitea'); + + $gitea = VcsFactory::getAdapter('gitea', $cache); + + $valid = empty($secretKey) ? true : $gitea->validateWebhookEvent($payload, $signature, $secretKey); + Span::add('vcs.gitea.event.signature.valid', $valid); + + if (!$valid) { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Invalid webhook payload signature.'); + } + + $parsedPayload = $gitea->getEvent($event, $payload); + + match ($event) { + 'push' => $this->handlePushEvent($parsedPayload, $gitea, $cache, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform), + 'pull_request' => $this->handlePullRequestEvent($parsedPayload, $gitea, $cache, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform), + default => null, + }; + + $response->json($parsedPayload); + } + + private function handlePushEvent( + array $parsedPayload, + Gitea $gitea, + Cache $cache, + Database $dbForPlatform, + Authorization $authorization, + Build $queueForBuilds, + callable $getProjectDB, + array $platform, + ) { + $providerBranchDeleted = $parsedPayload['branchDeleted'] ?? false; + $providerBranch = $parsedPayload['branch'] ?? ''; + $providerBranchUrl = $parsedPayload['branchUrl'] ?? ''; + $providerRepositoryId = $parsedPayload['repositoryId'] ?? ''; + $providerRepositoryName = $parsedPayload['repositoryName'] ?? ''; + $providerRepositoryUrl = $parsedPayload['repositoryUrl'] ?? ''; + $providerCommitHash = $parsedPayload['commitHash'] ?? ''; + $providerRepositoryOwner = $parsedPayload['owner'] ?? ''; + $providerCommitAuthorName = $parsedPayload['headCommitAuthorName'] ?? ''; + $providerCommitAuthorEmail = $parsedPayload['headCommitAuthorEmail'] ?? ''; + $providerCommitAuthorUrl = $parsedPayload['authorUrl'] ?? ''; + $providerCommitMessage = $parsedPayload['headCommitMessage'] ?? ''; + $providerCommitUrl = $parsedPayload['headCommitUrl'] ?? ''; + + Span::add('vcs.gitea.event.repo.id', $providerRepositoryId); + Span::add('vcs.gitea.event.repo.name', $providerRepositoryName); + Span::add('vcs.gitea.event.branch', $providerBranch); + + // Find the installation by looking up the repository's organization and provider + $installation = $this->findInstallation($providerRepositoryOwner, $dbForPlatform, $authorization); + if ($installation === null) { + return; + } + + $vcs = VcsFactory::getInitializedAdapter('gitea', $installation, $cache); + + $repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [ + Query::equal('providerRepositoryId', [$providerRepositoryId]), + Query::limit(100), + ])); + + if ($providerCommitAuthorEmail !== APP_VCS_COMMIT_EMAIL && !$providerBranchDeleted) { + $this->createGitDeployments($vcs, '', $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + } + } + + private function handlePullRequestEvent( + array $parsedPayload, + Gitea $gitea, + Cache $cache, + Database $dbForPlatform, + Authorization $authorization, + Build $queueForBuilds, + callable $getProjectDB, + array $platform, + ) { + $action = $parsedPayload['action'] ?? ''; + + if ($action === 'opened' || $action === 'reopened' || $action === 'synchronize' || $action === 'synchronized') { + $providerBranch = $parsedPayload['branch'] ?? ''; + $providerBranchUrl = $parsedPayload['branchUrl'] ?? ''; + $providerRepositoryId = $parsedPayload['repositoryId'] ?? ''; + $providerRepositoryName = $parsedPayload['repositoryName'] ?? ''; + $providerRepositoryUrl = $parsedPayload['repositoryUrl'] ?? ''; + $providerPullRequestId = $parsedPayload['pullRequestNumber'] ?? ''; + $providerCommitHash = $parsedPayload['commitHash'] ?? ''; + $providerRepositoryOwner = $parsedPayload['owner'] ?? ''; + $external = $parsedPayload['external'] ?? true; + $providerCommitUrl = $parsedPayload['headCommitUrl'] ?? ''; + $providerCommitAuthorUrl = $parsedPayload['authorUrl'] ?? ''; + + Span::add('vcs.gitea.event.repo.id', $providerRepositoryId); + Span::add('vcs.gitea.event.repo.name', $providerRepositoryName); + Span::add('vcs.gitea.event.branch', $providerBranch); + + // Ignore sync for non-external + if (!$external && ($action === 'synchronize' || $action === 'synchronized')) { + return; + } + + $installation = $this->findInstallation($providerRepositoryOwner, $dbForPlatform, $authorization); + if ($installation === null) { + return; + } + + $vcs = VcsFactory::getInitializedAdapter('gitea', $installation, $cache); + + $commitDetails = $vcs->getCommit($providerRepositoryOwner, $providerRepositoryName, $providerCommitHash); + $providerCommitAuthor = $commitDetails['commitAuthor'] ?? ''; + $providerCommitMessage = $commitDetails['commitMessage'] ?? ''; + + $repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [ + Query::equal('providerRepositoryId', [$providerRepositoryId]), + Query::orderDesc('$createdAt') + ])); + + $this->createGitDeployments($vcs, '', $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + } elseif ($action === 'closed') { + $providerRepositoryId = $parsedPayload['repositoryId'] ?? ''; + $providerPullRequestId = $parsedPayload['pullRequestNumber'] ?? ''; + $external = $parsedPayload['external'] ?? true; + + if ($external) { + $repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [ + Query::equal('providerRepositoryId', [$providerRepositoryId]), + Query::orderDesc('$createdAt') + ])); + + foreach ($repositories as $repository) { + $providerPullRequestIds = $repository->getAttribute('providerPullRequestIds', []); + + if (\in_array($providerPullRequestId, $providerPullRequestIds)) { + $providerPullRequestIds = \array_diff($providerPullRequestIds, [$providerPullRequestId]); + $authorization->skip(fn () => $dbForPlatform->updateDocument('repositories', $repository->getId(), new Document(['providerPullRequestIds' => $providerPullRequestIds]))); + } + } + } + } + } + + /** + * Find an installation by organization name for Gitea provider. + */ + private function findInstallation(string $organization, Database $dbForPlatform, Authorization $authorization): ?Document + { + $installation = $authorization->skip(fn () => $dbForPlatform->findOne('installations', [ + Query::equal('organization', [$organization]), + Query::equal('provider', ['gitea']), + ])); + + if ($installation->isEmpty()) { + return null; + } + + return $installation; + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 4ed4241d25..d4c51e7b3a 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -8,12 +8,12 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; use Utopia\Validator\Text; -use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\RepositoryNotFound; class XList extends Action @@ -29,7 +29,8 @@ class XList extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId/branches') + ->setHttpPath('/v1/vcs/installations/:installationId/providerRepositories/:providerRepositoryId/branches') + ->httpAlias('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId/branches') ->desc('List repository branches') ->groups(['api', 'vcs']) ->label('scope', 'vcs.read') @@ -49,7 +50,7 @@ class XList extends Action )) ->param('installationId', '', new Text(256), 'Installation Id') ->param('providerRepositoryId', '', new Text(256), 'Repository Id') - ->inject('gitHub') + ->inject('cache') ->inject('response') ->inject('dbForPlatform') ->callback($this->action(...)); @@ -58,7 +59,7 @@ class XList extends Action public function action( string $installationId, string $providerRepositoryId, - GitHub $github, + Cache $cache, Response $response, Database $dbForPlatform ) { @@ -68,14 +69,15 @@ class XList extends Action throw new Exception(Exception::INSTALLATION_NOT_FOUND); } + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); + $accessToken = $installation->getAttribute('personalAccessToken', ''); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); + + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId) ?? ''; - $owner = $github->getOwnerName($providerInstallationId) ?? ''; try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $vcs->getRepositoryName($providerRepositoryId) ?? ''; if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -83,7 +85,7 @@ class XList extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $branches = $github->listBranches($owner, $repositoryName) ?? []; + $branches = $vcs->listBranches($owner, $repositoryName) ?? []; $response->dynamic(new Document([ 'branches' => \array_map(function ($branch) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php index a0dcec8590..89d886bfed 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php @@ -8,12 +8,14 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; use Utopia\Validator\Text; use Utopia\VCS\Adapter\Git\GitHub; +use Utopia\VCS\Adapter\Git\Gitea; use Utopia\VCS\Exception\RepositoryNotFound; class Get extends Action @@ -29,7 +31,8 @@ class Get extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId/contents') + ->setHttpPath('/v1/vcs/installations/:installationId/providerRepositories/:providerRepositoryId/contents') + ->httpAlias('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId/contents') ->desc('Get files and directories of a VCS repository') ->groups(['api', 'vcs']) ->label('scope', 'vcs.read') @@ -51,7 +54,7 @@ class Get extends Action ->param('providerRepositoryId', '', new Text(256), 'Repository Id') ->param('providerRootDirectory', '', new Text(256, 0), 'Path to get contents of nested directory', true) ->param('providerReference', '', new Text(256, 0), 'Git reference (branch, tag, commit) to get contents from', true) - ->inject('gitHub') + ->inject('cache') ->inject('response') ->inject('dbForPlatform') ->callback($this->action(...)); @@ -62,7 +65,7 @@ class Get extends Action string $providerRepositoryId, string $providerRootDirectory, string $providerReference, - GitHub $github, + Cache $cache, Response $response, Database $dbForPlatform ) { @@ -72,14 +75,13 @@ class Get extends Action throw new Exception(Exception::INSTALLATION_NOT_FOUND); } + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); - $owner = $github->getOwnerName($providerInstallationId); + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $vcs->getRepositoryName($providerRepositoryId) ?? ''; if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -87,12 +89,13 @@ class Get extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $contents = $github->listRepositoryContents($owner, $repositoryName, $providerRootDirectory, $providerReference); + $contents = $vcs->listRepositoryContents($owner, $repositoryName, $providerRootDirectory, $providerReference); + $contentsDir = ($vcs instanceof GitHub) ? GitHub::CONTENTS_DIRECTORY : Gitea::CONTENTS_DIRECTORY; $vcsContents = []; foreach ($contents as $content) { $isDirectory = false; - if ($content['type'] === GitHub::CONTENTS_DIRECTORY) { + if ($content['type'] === $contentsDir) { $isDirectory = true; } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php index 04003812f8..b152efce83 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\VCS\Http\Installations\Repositories; +use Appwrite\Auth\OAuth2\Gitea as OAuth2Gitea; use Appwrite\Auth\OAuth2\Github as OAuth2Github; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; @@ -9,15 +10,15 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -use Utopia\VCS\Adapter\Git\GitHub; class Create extends Action { @@ -32,7 +33,8 @@ class Create extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/vcs/github/installations/:installationId/providerRepositories') + ->setHttpPath('/v1/vcs/installations/:installationId/providerRepositories') + ->httpAlias('/v1/vcs/github/installations/:installationId/providerRepositories') ->desc('Create repository') ->groups(['api', 'vcs']) ->label('scope', 'vcs.write') @@ -53,7 +55,7 @@ class Create extends Action ->param('installationId', '', new Text(256), 'Installation Id') ->param('name', '', new Text(256), 'Repository name (slug)') ->param('private', '', new Boolean(false), 'Mark repository public or private') - ->inject('gitHub') + ->inject('cache') ->inject('user') ->inject('response') ->inject('dbForPlatform') @@ -64,7 +66,7 @@ class Create extends Action string $installationId, string $name, bool $private, - GitHub $github, + Cache $cache, Document $user, Response $response, Database $dbForPlatform @@ -75,28 +77,38 @@ class Create extends Action throw new Exception(Exception::INSTALLATION_NOT_FOUND); } + $provider = $installation->getAttribute('provider', 'github'); + if ($installation->getAttribute('personal', false) === true) { - $oauth2 = new OAuth2Github(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), ""); + $clientId = VcsFactory::getClientId($provider); + $clientSecret = VcsFactory::getClientSecret($provider); $accessToken = $installation->getAttribute('personalAccessToken'); $refreshToken = $installation->getAttribute('personalRefreshToken'); $accessTokenExpiry = $installation->getAttribute('personalAccessTokenExpiry'); - if (empty($accessToken) || empty($refreshToken) || empty($accessTokenExpiry)) { - $identity = $dbForPlatform->findOne('identities', [ - Query::equal('provider', ['github']), - Query::equal('userInternalId', [$user->getSequence()]), - ]); - if ($identity->isEmpty()) { - throw new Exception(Exception::USER_IDENTITY_NOT_FOUND); - } + if ($provider === 'github') { + $oauth2 = new OAuth2Github($clientId, $clientSecret, ''); - $accessToken = $accessToken ?? $identity->getAttribute('providerAccessToken'); - $refreshToken = $refreshToken ?? $identity->getAttribute('providerRefreshToken'); - $accessTokenExpiry = $accessTokenExpiry ?? $identity->getAttribute('providerAccessTokenExpiry'); + if (empty($accessToken) || empty($refreshToken) || empty($accessTokenExpiry)) { + $identity = $dbForPlatform->findOne('identities', [ + Query::equal('provider', ['github']), + Query::equal('userInternalId', [$user->getSequence()]), + ]); + if ($identity->isEmpty()) { + throw new Exception(Exception::USER_IDENTITY_NOT_FOUND); + } + + $accessToken = $accessToken ?? $identity->getAttribute('providerAccessToken'); + $refreshToken = $refreshToken ?? $identity->getAttribute('providerRefreshToken'); + $accessTokenExpiry = $accessTokenExpiry ?? $identity->getAttribute('providerAccessTokenExpiry'); + } + } else { + $endpoint = VcsFactory::getEndpoint($provider); + $oauth2 = new OAuth2Gitea($clientId, $clientSecret, '', [], [], $endpoint); } - $isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now'); + $isExpired = !empty($accessTokenExpiry) && new \DateTime($accessTokenExpiry) < new \DateTime('now'); if ($isExpired) { $oauth2->refreshTokens($refreshToken); @@ -106,13 +118,13 @@ class Create extends Action $verificationId = $oauth2->getUserID($accessToken); if (empty($verificationId)) { - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, "Another request is currently refreshing OAuth token. Please try again."); + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, 'Another request is currently refreshing OAuth token. Please try again.'); } $installation = $installation ->setAttribute('personalAccessToken', $accessToken) ->setAttribute('personalRefreshToken', $refreshToken) - ->setAttribute('personalAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$oauth2->getAccessTokenExpiry(''))); + ->setAttribute('personalAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int) $oauth2->getAccessTokenExpiry(''))); $dbForPlatform->updateDocument('installations', $installation->getId(), new Document([ 'personalAccessToken' => $installation->getAttribute('personalAccessToken'), @@ -124,19 +136,17 @@ class Create extends Action try { $repository = $oauth2->createRepository($accessToken, $name, $private); } catch (Exception $exception) { - throw new Exception(Exception::GENERAL_PROVIDER_FAILURE, "GitHub failed to process the request: " . $exception->getMessage()); + throw new Exception(Exception::GENERAL_PROVIDER_FAILURE, "Provider failed to process the request: " . $exception->getMessage()); } } else { + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId); + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId); try { - $repository = $github->createRepository($owner, $name, $private); + $repository = $vcs->createRepository($owner, $name, $private); } catch (Exception $exception) { - throw new Exception(Exception::GENERAL_PROVIDER_FAILURE, "GitHub failed to process the request: " . $exception->getMessage()); + throw new Exception(Exception::GENERAL_PROVIDER_FAILURE, "Provider failed to process the request: " . $exception->getMessage()); } } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 5dd5c6dcfa..4f6087a057 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -46,11 +46,11 @@ use Utopia\Detector\Detector\Framework; use Utopia\Detector\Detector\Packager; use Utopia\Detector\Detector\Runtime; use Utopia\Detector\Detector\Strategy; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\FileNotFound; use Utopia\VCS\Exception\RepositoryNotFound; @@ -67,8 +67,10 @@ class Create extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/vcs/github/installations/:installationId/detections') + ->setHttpPath('/v1/vcs/installations/:installationId/detections') + ->httpAlias('/v1/vcs/github/installations/:installationId/detections') ->httpAlias('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId/detection') + ->httpAlias('/v1/vcs/installations/:installationId/providerRepositories/:providerRepositoryId/detection') ->desc('Create repository detection') ->groups(['api', 'vcs']) ->label('scope', 'vcs.write') @@ -93,7 +95,7 @@ class Create extends Action ->param('providerRepositoryId', '', new Text(256), 'Repository Id') ->param('type', '', new WhiteList(['runtime', 'framework']), 'Detector type. Must be one of the following: runtime, framework') ->param('providerRootDirectory', '', new Text(256, 0), 'Path to Root Directory', true) - ->inject('gitHub') + ->inject('cache') ->inject('response') ->inject('dbForPlatform') ->callback($this->action(...)); @@ -104,7 +106,7 @@ class Create extends Action string $providerRepositoryId, string $type, string $providerRootDirectory, - GitHub $github, + Cache $cache, Response $response, Database $dbForPlatform ) { @@ -114,14 +116,13 @@ class Create extends Action throw new Exception(Exception::INSTALLATION_NOT_FOUND); } + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); - $owner = $github->getOwnerName($providerInstallationId); + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $vcs->getRepositoryName($providerRepositoryId) ?? ''; if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -129,9 +130,9 @@ class Create extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $files = $github->listRepositoryContents($owner, $repositoryName, $providerRootDirectory); + $files = $vcs->listRepositoryContents($owner, $repositoryName, $providerRootDirectory); $files = \array_column($files, 'name'); - $languages = $github->listRepositoryLanguages($owner, $repositoryName); + $languages = $vcs->listRepositoryLanguages($owner, $repositoryName); $detector = new Packager(); foreach ($files as $file) { @@ -148,7 +149,7 @@ class Create extends Action if ($type === 'framework') { $packages = ''; try { - $contentResponse = $github->getRepositoryContent($owner, $repositoryName, \rtrim($providerRootDirectory, '/') . '/package.json'); + $contentResponse = $vcs->getRepositoryContent($owner, $repositoryName, \rtrim($providerRootDirectory, '/') . '/package.json'); $packages = $contentResponse['content'] ?? ''; } catch (FileNotFound $e) { // Continue detection without package.json @@ -280,7 +281,7 @@ class Create extends Action $wg->add(); go(function () use ($github, $owner, $repositoryName, $providerRootDirectory, $file, $wg, &$envs) { try { - $contentResponse = $github->getRepositoryContent($owner, $repositoryName, \rtrim($providerRootDirectory, '/') . '/' . $file); + $contentResponse = $vcs->getRepositoryContent($owner, $repositoryName, \rtrim($providerRootDirectory, '/') . '/' . $file); $envFile = $contentResponse['content'] ?? ''; $configAdapter = new ConfigDotenv(); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php index 52b94cd525..cfa9dbb8d8 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php @@ -8,10 +8,11 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; use Utopia\Validator\Text; use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\RepositoryNotFound; @@ -29,7 +30,8 @@ class Get extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId') + ->setHttpPath('/v1/vcs/installations/:installationId/providerRepositories/:providerRepositoryId') + ->httpAlias('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId') ->desc('Get repository') ->groups(['api', 'vcs']) ->label('scope', 'vcs.read') @@ -49,7 +51,7 @@ class Get extends Action )) ->param('installationId', '', new Text(256), 'Installation Id') ->param('providerRepositoryId', '', new Text(256), 'Repository Id') - ->inject('gitHub') + ->inject('cache') ->inject('response') ->inject('dbForPlatform') ->callback($this->action(...)); @@ -58,7 +60,7 @@ class Get extends Action public function action( string $installationId, string $providerRepositoryId, - GitHub $github, + Cache $cache, Response $response, Database $dbForPlatform ) { @@ -68,14 +70,13 @@ class Get extends Action throw new Exception(Exception::INSTALLATION_NOT_FOUND); } + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = VcsFactory::getOwnerName($vcs, $provider, $providerInstallationId, $providerRepositoryId) ?? ''; try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $vcs->getRepositoryName($providerRepositoryId) ?? ''; if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -83,12 +84,12 @@ class Get extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $repository = $github->getRepository($owner, $repositoryName); + $repository = $vcs->getRepository($owner, $repositoryName); - $authorized = $github->hasAccessToAllRepositories(); - if (!$authorized) { + $authorized = $vcs->hasAccessToAllRepositories(); + if (!$authorized && $vcs instanceof GitHub) { try { - $installationRepository = $github->getInstallationRepository($repositoryName); + $installationRepository = $vcs->getInstallationRepository($repositoryName); if (!empty($installationRepository)) { $authorized = true; } @@ -100,8 +101,8 @@ class Get extends Action $repository['id'] = \strval($repository['id']) ?? ''; $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); - $repository['provider'] = $installation->getAttribute('provider', ''); - $repository['defaultBranch'] = $repository['default_branch'] ?? ''; + $repository['provider'] = $provider; + $repository['defaultBranch'] = $repository['default_branch'] ?? ''; $repository['authorized'] = $authorized; $repository['providerInstallationId'] = $providerInstallationId; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index d5b2b48175..d1898534ff 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -50,11 +50,11 @@ use Utopia\Detector\Detector\Framework; use Utopia\Detector\Detector\Packager; use Utopia\Detector\Detector\Runtime; use Utopia\Detector\Detector\Strategy; +use Appwrite\Vcs\VcsFactory; +use Utopia\Cache\Cache; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\FileNotFound; use function Swoole\Coroutine\batch; @@ -72,7 +72,8 @@ class XList extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vcs/github/installations/:installationId/providerRepositories') + ->setHttpPath('/v1/vcs/installations/:installationId/providerRepositories') + ->httpAlias('/v1/vcs/github/installations/:installationId/providerRepositories') ->desc('List repositories') ->groups(['api', 'vcs']) ->label('scope', 'vcs.read') @@ -97,7 +98,7 @@ class XList extends Action ->param('type', '', new WhiteList(['runtime', 'framework']), 'Detector type. Must be one of the following: runtime, framework') ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('gitHub') + ->inject('cache') ->inject('response') ->inject('dbForPlatform') ->callback($this->action(...)); @@ -108,7 +109,7 @@ class XList extends Action string $type, string $search, array $queries, - GitHub $github, + Cache $cache, Response $response, Database $dbForPlatform ) { @@ -122,10 +123,9 @@ class XList extends Action throw new Exception(Exception::INSTALLATION_NOT_FOUND); } + $provider = $installation->getAttribute('provider', 'github'); $providerInstallationId = $installation->getAttribute('providerInstallationId'); - $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); - $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); - $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); + $vcs = VcsFactory::getInitializedAdapter($provider, $installation, $cache); $queries = Query::parseQueries($queries); $limitQuery = current(array_filter($queries, fn ($query) => $query->getMethod() === Query::TYPE_LIMIT)); @@ -139,8 +139,8 @@ class XList extends Action } $page = ($offset / $limit) + 1; - $owner = $github->getOwnerName($providerInstallationId); - ['items' => $repos, 'total' => $total] = $github->searchRepositories($owner, $page, $limit, $search); + $owner = $installation->getAttribute('organization', ''); + ['items' => $repos, 'total' => $total] = $vcs->searchRepositories($owner, $page, $limit, $search); $repos = \array_map(function ($repo) use ($installation) { $repo['id'] = \strval($repo['id'] ?? ''); @@ -152,9 +152,9 @@ class XList extends Action return $repo; }, $repos); - $repos = batch(\array_map(function ($repo) use ($type, $github) { - return function () use ($repo, $type, $github) { - $files = $github->listRepositoryContents($repo['organization'], $repo['name'], ''); + $repos = batch(\array_map(function ($repo) use ($type, $vcs) { + return function () use ($repo, $type, $vcs) { + $files = $vcs->listRepositoryContents($repo['organization'], $repo['name'], ''); $files = \array_column($files, 'name'); $detector = new Packager(); @@ -172,7 +172,7 @@ class XList extends Action if ($type === 'framework') { $packages = ''; try { - $contentResponse = $github->getRepositoryContent($repo['organization'], $repo['name'], 'package.json'); + $contentResponse = $vcs->getRepositoryContent($repo['organization'], $repo['name'], 'package.json'); $packages = $contentResponse['content'] ?? ''; } catch (FileNotFound $e) { // Continue detection without package.json @@ -214,7 +214,7 @@ class XList extends Action } $repo['framework'] = $framework; } else { - $languages = $github->listRepositoryLanguages($repo['organization'], $repo['name']); + $languages = $vcs->listRepositoryLanguages($repo['organization'], $repo['name']); $strategies = [ new Strategy(Strategy::FILEMATCH), @@ -275,9 +275,9 @@ class XList extends Action } $wg->add(); - go(function () use ($github, $repo, $file, $wg, &$envs) { + go(function () use ($vcs, $repo, $file, $wg, &$envs) { try { - $contentResponse = $github->getRepositoryContent($repo['organization'], $repo['name'], $file); + $contentResponse = $vcs->getRepositoryContent($repo['organization'], $repo['name'], $file); $envFile = $contentResponse['content'] ?? ''; $configAdapter = new ConfigDotenv(); diff --git a/src/Appwrite/Platform/Modules/VCS/Services/Http.php b/src/Appwrite/Platform/Modules/VCS/Services/Http.php index ae766ad00e..db840203c4 100644 --- a/src/Appwrite/Platform/Modules/VCS/Services/Http.php +++ b/src/Appwrite/Platform/Modules/VCS/Services/Http.php @@ -2,6 +2,9 @@ namespace Appwrite\Platform\Modules\VCS\Services; +use Appwrite\Platform\Modules\VCS\Http\Gitea\Authorize\Get as GetGiteaAuthorize; +use Appwrite\Platform\Modules\VCS\Http\Gitea\Callback\Get as GetGiteaCallback; +use Appwrite\Platform\Modules\VCS\Http\Gitea\Events\Create as CreateGiteaEvent; use Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External\Update as UpdateExternalDeployment; use Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\Get as GetGitHubAuthorize; use Appwrite\Platform\Modules\VCS\Http\GitHub\Callback\Get as GetGitHubCallback; @@ -26,6 +29,12 @@ class Http extends Service // GitHub Authorization & Callback $this->addAction(GetGitHubAuthorize::getName(), new GetGitHubAuthorize()); $this->addAction(GetGitHubCallback::getName(), new GetGitHubCallback()); + + // Gitea Authorization & Callback + $this->addAction(GetGiteaAuthorize::getName(), new GetGiteaAuthorize()); + $this->addAction(GetGiteaCallback::getName(), new GetGiteaCallback()); + + // External Deployments $this->addAction(UpdateExternalDeployment::getName(), new UpdateExternalDeployment()); // Installations @@ -41,7 +50,8 @@ class Http extends Service $this->addAction(GetRepositoryContents::getName(), new GetRepositoryContents()); $this->addAction(CreateRepositoryDetections::getName(), new CreateRepositoryDetections()); - // Events + // Events (per-provider webhook handlers) $this->addAction(CreateGitHubEvent::getName(), new CreateGitHubEvent()); + $this->addAction(CreateGiteaEvent::getName(), new CreateGiteaEvent()); } } diff --git a/src/Appwrite/Vcs/VcsFactory.php b/src/Appwrite/Vcs/VcsFactory.php new file mode 100644 index 0000000000..006a6f56e6 --- /dev/null +++ b/src/Appwrite/Vcs/VcsFactory.php @@ -0,0 +1,267 @@ + new GitHub($cache), + 'gitea' => self::createGiteaBasedAdapter($provider, $cache), + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, "Unsupported VCS provider: {$provider}"), + }; + } + + /** + * Initialize a VCS adapter with credentials from the installation document and environment. + */ + public static function initializeAdapter(Git $adapter, string $provider, Document $installation): void + { + $providerInstallationId = $installation->getAttribute('providerInstallationId', ''); + + match ($provider) { + 'github' => $adapter->initializeVariables( + $providerInstallationId, + System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY', ''), + System::getEnv('_APP_VCS_GITHUB_APP_ID', ''), + ), + 'gitea' => $adapter->initializeVariables( + '', + '', + null, + $installation->getAttribute('personalAccessToken', ''), + $installation->getAttribute('personalRefreshToken', ''), + ), + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, "Unsupported VCS provider: {$provider}"), + }; + } + + /** + * Create and initialize a VCS adapter in one call. + */ + public static function getInitializedAdapter(string $provider, Document $installation, Cache $cache): Git + { + $adapter = self::getAdapter($provider, $cache); + self::initializeAdapter($adapter, $provider, $installation); + return $adapter; + } + + /** + * Get the browser-facing base URL for a VCS provider. + * Used for constructing user-visible URLs (repo, branch, commit links). + */ + public static function getProviderUrl(string $provider): string + { + return match ($provider) { + 'github' => 'https://github.com', + 'gitea' => rtrim(self::getBrowserEndpoint($provider), '/'), + default => '', + }; + } + + /** + * Build a branch URL for the given provider. + */ + public static function getBranchUrl(string $provider, string $owner, string $repo, string $branch): string + { + $base = self::getProviderUrl($provider); + if (empty($base)) { + return ''; + } + + return match ($provider) { + 'github' => "{$base}/{$owner}/{$repo}/tree/{$branch}", + 'gitea' => "{$base}/{$owner}/{$repo}/src/branch/{$branch}", + default => '', + }; + } + + /** + * Build a repository URL for the given provider. + */ + public static function getRepoUrl(string $provider, string $owner, string $repo): string + { + $base = self::getProviderUrl($provider); + if (empty($base)) { + return ''; + } + + return "{$base}/{$owner}/{$repo}"; + } + + /** + * Build a commit URL for the given provider. + */ + public static function getCommitUrl(string $provider, string $owner, string $repo, string $hash): string + { + $base = self::getProviderUrl($provider); + if (empty($base)) { + return ''; + } + + return match ($provider) { + 'github' => "{$base}/{$owner}/{$repo}/commit/{$hash}", + 'gitea' => "{$base}/{$owner}/{$repo}/commit/{$hash}", + default => '', + }; + } + + /** + * Get the webhook secret env var for a provider. + */ + public static function getWebhookSecret(string $provider): string + { + return match ($provider) { + 'github' => System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', ''), + 'gitea' => System::getEnv('_APP_VCS_GITEA_WEBHOOK_SECRET', ''), + default => '', + }; + } + + /** + * Get the OAuth2 client ID env var for a provider. + */ + public static function getClientId(string $provider): string + { + return match ($provider) { + 'github' => System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), + 'gitea' => System::getEnv('_APP_VCS_GITEA_CLIENT_ID', ''), + default => '', + }; + } + + /** + * Get the OAuth2 client secret env var for a provider. + */ + public static function getClientSecret(string $provider): string + { + return match ($provider) { + 'github' => System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), + 'gitea' => System::getEnv('_APP_VCS_GITEA_CLIENT_SECRET', ''), + default => '', + }; + } + + /** + * Get the endpoint URL for a self-hosted provider. + */ + public static function getEndpoint(string $provider): string + { + return match ($provider) { + 'gitea' => System::getEnv('_APP_VCS_GITEA_ENDPOINT', ''), + default => '', + }; + } + + /** + * Get the browser-facing endpoint URL for a self-hosted provider. + * + * In Docker, the internal endpoint (e.g. http://gitea:3000) differs from + * the URL the browser can reach (e.g. http://localhost:9510). This returns + * the browser-facing URL, falling back to the internal endpoint if not set. + */ + public static function getBrowserEndpoint(string $provider): string + { + return match ($provider) { + 'gitea' => System::getEnv('_APP_VCS_GITEA_BROWSER_ENDPOINT', System::getEnv('_APP_VCS_GITEA_ENDPOINT', '')), + default => '', + }; + } + + /** + * Get the owner name from a VCS adapter, handling provider differences. + * + * GitHub uses installationId, Gitea-based providers require repositoryId. + */ + public static function getOwnerName(Git $adapter, string $provider, string $providerInstallationId, ?string $providerRepositoryId = null): string + { + return match ($provider) { + 'github' => $adapter->getOwnerName($providerInstallationId), + 'gitea' => $adapter->getOwnerName('', !empty($providerRepositoryId) ? intval($providerRepositoryId) : null), + default => '', + }; + } + + /** + * Check if a provider is configured via environment variables. + */ + public static function isProviderConfigured(string $provider): bool + { + return match ($provider) { + 'github' => !empty(System::getEnv('_APP_VCS_GITHUB_APP_NAME', '')) + && !empty(System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY', '')) + && !empty(System::getEnv('_APP_VCS_GITHUB_APP_ID', '')) + && !empty(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', '')) + && !empty(System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', '')), + 'gitea' => !empty(System::getEnv('_APP_VCS_GITEA_ENDPOINT', '')), + default => false, + }; + } + + /** + * Get all configured VCS providers. + * + * @return string[] + */ + public static function getConfiguredProviders(): array + { + $configured = []; + foreach (APP_VCS_PROVIDERS as $provider) { + if (self::isProviderConfigured($provider)) { + $configured[] = $provider; + } + } + return $configured; + } + + /** + * Create a webhook on a repository for non-GitHub providers. + * + * GitHub manages webhooks via its App — no per-repo setup needed. + * Gitea (and similar) require explicit webhook creation on each repository. + */ + public static function createRepositoryWebhook(Git $adapter, string $provider, string $owner, string $repositoryName): void + { + if ($provider === 'github') { + return; + } + + // Use the internal webhook URL if set (for Docker-to-Docker communication), + // otherwise fall back to the public domain. + $internalUrl = System::getEnv('_APP_VCS_WEBHOOK_URL', ''); + if (empty($internalUrl)) { + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http://' : 'https://'; + $internalUrl = $protocol . System::getEnv('_APP_DOMAIN', 'localhost'); + } + + $webhookUrl = rtrim($internalUrl, '/') . "/v1/vcs/{$provider}/events"; + $webhookSecret = self::getWebhookSecret($provider); + + $adapter->createWebhook($owner, $repositoryName, $webhookUrl, $webhookSecret, ['push', 'pull_request']); + } + + /** + * Create a Gitea-based adapter with the correct endpoint. + */ + private static function createGiteaBasedAdapter(string $provider, Cache $cache): Gitea + { + $adapter = new Gitea($cache); + $endpoint = self::getEndpoint($provider); + if (!empty($endpoint)) { + $adapter->setEndpoint($endpoint); + } + return $adapter; + } +}