diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index cd68c1b67..0a7efed99 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -6,10 +6,14 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; +use App\Livewire\Source\Gitlab\Change as GitlabSource; use App\Models\Application; use App\Models\ApplicationPreview; +use App\Models\GitlabApp; use Exception; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class Gitlab extends Controller @@ -17,6 +21,302 @@ class Gitlab extends Controller use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + public function redirect(Request $request) + { + try { + $code = $request->query('code'); + $state = $request->query('state'); + + if (! $code || ! $state) { + return redirect()->route('source.all')->with('error', 'Invalid GitLab OAuth callback. Missing code or state.'); + } + + // Validate the one-time, team-bound state (not a guessable source UUID) to stop forged callbacks from overwriting a source's tokens. + $payload = Cache::pull(GitlabSource::oauthStateCacheKey($state)); + $team_id = $request->user()?->currentTeam()?->id; + if (! is_array($payload) || is_null($team_id) || (int) data_get($payload, 'team_id') !== (int) $team_id) { + return redirect()->route('source.all')->with('error', 'Invalid or expired GitLab OAuth state. Please start the authorization again.'); + } + + $gitlabApp = GitlabApp::whereKey(data_get($payload, 'gitlab_app_id'))->firstOrFail(); + + $baseUrl = rtrim($gitlabApp->html_url, '/'); + + $response = Http::asForm()->post("{$baseUrl}/oauth/token", [ + 'client_id' => $gitlabApp->client_id, + 'client_secret' => $gitlabApp->client_secret, + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $gitlabApp->redirect_uri, + ]); + + if (! $response->successful()) { + $error = data_get($response->json(), 'error_description', 'Token exchange failed'); + + return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid]) + ->with('error', "GitLab OAuth failed: {$error}"); + } + + $data = $response->json(); + $gitlabApp->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'], + 'expires_at' => time() + ($data['expires_in'] ?? 7200), + ]); + + return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid]); + } catch (Exception $e) { + return redirect()->route('source.all')->with('error', $e->getMessage()); + } + } + + public function normal(Request $request) + { + try { + $return_payloads = collect([]); + $payload = $request->collect(); + $x_gitlab_token = $request->header('X-Gitlab-Token'); + $object_kind = data_get($payload, 'object_kind'); + $project_id = data_get($payload, 'project.id'); + + $allowed_events = ['push', 'merge_request']; + if (! in_array($object_kind, $allowed_events)) { + return response([ + 'status' => 'failed', + 'message' => 'Event not allowed. Only push and merge_request events are allowed.', + ]); + } + + if (empty($x_gitlab_token)) { + auditLogWebhookFailure('gitlab', 'webhook_token_missing', [ + 'event' => $object_kind, + ]); + + return response([ + 'status' => 'failed', + 'message' => 'Missing X-Gitlab-Token header.', + ], 401); + } + + $gitlab_app = GitlabApp::where('webhook_token', $x_gitlab_token)->first(); + if (! $gitlab_app) { + auditLogWebhookFailure('gitlab', 'invalid_token', [ + 'event' => $object_kind, + ]); + + return response([ + 'status' => 'failed', + 'message' => 'Invalid webhook token.', + ], 401); + } + + $applications = Application::where('source_id', $gitlab_app->id) + ->where('source_type', GitlabApp::class) + ->where('repository_project_id', $project_id); + + if ($object_kind === 'push') { + $branch = data_get($payload, 'ref'); + if (Str::isMatch('/refs\/heads\/*/', $branch)) { + $branch = Str::after($branch, 'refs/heads/'); + } + if (! $branch) { + return response([ + 'status' => 'failed', + 'message' => 'No branch found in the request.', + ]); + } + + $applications = $applications->where('git_branch', $branch)->get(); + $added_files = data_get($payload, 'commits.*.added'); + $removed_files = data_get($payload, 'commits.*.removed'); + $modified_files = data_get($payload, 'commits.*.modified'); + $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); + + foreach ($applications as $application) { + if (! $application->destination->server->isFunctional()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]); + + continue; + } + + if (! $application->isDeployable()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Deployments disabled', + ]); + + continue; + } + + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if (! $is_watch_path_triggered && ! blank($application->watch_paths)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths.', + ]); + + continue; + } + + if ($skip_deploy_commits) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci].', + ]); + + continue; + } + + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + commit: data_get($payload, 'after', 'HEAD'), + force_rebuild: false, + is_webhook: true, + ); + + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } + + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitlab', + 'mode' => 'app', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + ]); + + $return_payloads->push([ + 'application' => $application->name, + 'status' => $result['status'] ?? 'success', + 'message' => $result['message'] ?? 'Deployment queued.', + ]); + } + } + + if ($object_kind === 'merge_request') { + $action = data_get($payload, 'object_attributes.action'); + $branch = data_get($payload, 'object_attributes.source_branch'); + $base_branch = data_get($payload, 'object_attributes.target_branch'); + $pull_request_id = data_get($payload, 'object_attributes.iid'); + $pull_request_html_url = data_get($payload, 'object_attributes.url'); + $pull_request_title = data_get($payload, 'object_attributes.title'); + $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); + + $applications = $applications->where('git_branch', $base_branch)->get(); + + foreach ($applications as $application) { + if (! $application->destination->server->isFunctional()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]); + + continue; + } + + if (in_array($action, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'])) { + if (! $application->isPRDeployable()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Preview deployments disabled', + ]); + + continue; + } + + if ($skip_deploy_pr) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title or latest commit contains [skip cd] or [skip ci].', + ]); + + continue; + } + + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + + if (! $found) { + if ($application->build_pack === 'dockercompose') { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'gitlab', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + $pr_app->generate_preview_fqdn_compose(); + } else { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'gitlab', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + $pr_app->generate_preview_fqdn(); + } + } + + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + commit: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'), + force_rebuild: false, + is_webhook: true, + git_type: 'gitlab', + ); + + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } + + $return_payloads->push([ + 'application' => $application->name, + 'status' => $result['status'] ?? 'success', + 'message' => $result['message'] ?? 'Preview Deployment queued', + ]); + } elseif (in_array($action, ['closed', 'close', 'merge'])) { + $found = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + + if ($found) { + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', + ]); + } + } + } + } + + return response($return_payloads); + } catch (Exception $e) { + return handleError($e); + } + } + public function manual(Request $request) { try { @@ -291,8 +591,6 @@ class Gitlab extends Controller } elseif ($action === 'closed' || $action === 'close' || $action === 'merge') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Use comprehensive cleanup that cancels active deployments, - // kills helper containers, and removes all PR containers CleanupPreviewDeployment::run($application, $pull_request_id, $found); $return_payloads->push([ diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index fe6a6397a..29f798d59 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -96,9 +96,19 @@ class Source extends Component private function getSources() { - // filter the current source out - $this->sources = currentTeam()->sources()->whereNotNull('app_id')->reject(function ($source) { - return $source->id === $this->application->source_id; + $this->sources = currentTeam()->sources()->filter(function ($source) { + if ($source->id === $this->application->source_id + && $source->getMorphClass() === $this->application->source_type) { + return false; + } + if ($source instanceof GithubApp) { + return ! is_null($source->app_id); + } + if ($source instanceof GitlabApp) { + return $source->isConnected(); + } + + return true; })->sortBy('name'); } @@ -137,7 +147,6 @@ class Source extends Component public function changeSource($sourceId, $sourceType) { - try { $this->authorize('update', $this->application); $allowedSourceTypes = [GithubApp::class, GitlabApp::class]; @@ -150,16 +159,24 @@ class Source extends Component $this->dispatch('configurationChanged'); ['repository' => $customRepository] = $this->application->customRepository(); - $repository = githubApi($this->application->source, "repos/{$customRepository}"); - $data = data_get($repository, 'data'); - $repository_project_id = data_get($data, 'id'); - if (isset($repository_project_id)) { - if ($this->application->repository_project_id !== $repository_project_id) { - $this->application->repository_project_id = $repository_project_id; - $this->application->save(); + $repository_project_id = null; + + if ($sourceType === GithubApp::class) { + $repository = githubApi($source, "repos/{$customRepository}"); + $repository_project_id = data_get($repository, 'data.id'); + } elseif ($sourceType === GitlabApp::class) { + if ($source->isConnected()) { + $encoded = urlencode($customRepository); + $project = gitlabApi($source, "/projects/{$encoded}"); + $repository_project_id = data_get($project, 'data.id'); } } + if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) { + $this->application->repository_project_id = $repository_project_id; + $this->application->save(); + } + $this->application->refresh(); $this->getSources(); $this->dispatch('success', 'Source updated!'); diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php new file mode 100644 index 000000000..798a0dc50 --- /dev/null +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -0,0 +1,237 @@ +where('id', $this->gitlab_app_id)->firstOrFail(); + } + + public function mount() + { + $this->currentRoute = Route::currentRouteName(); + $this->parameters = get_route_parameters(); + $this->query = request()->query(); + $this->repositories = $this->branches = collect(); + $this->gitlab_apps = GitlabApp::private()->select(['id', 'name', 'html_url', 'team_id', 'is_system_wide', 'is_public'])->get(); + } + + public function updatedSelectedProjectId(): void + { + $this->loadBranches(); + } + + public function updatedBuildPack() + { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { + $this->show_is_static = true; + if (! $this->is_static) { + $this->port = 3000; + } + } elseif ($this->build_pack === 'static') { + $this->show_is_static = false; + $this->is_static = false; + $this->port = 80; + } else { + $this->show_is_static = false; + $this->is_static = false; + } + } + + public function loadRepositories($gitlab_app_id) + { + $this->repositories = collect(); + $this->branches = collect(); + $this->total_branches_count = 0; + $this->page = 1; + $this->selected_gitlab_app_id = $gitlab_app_id; + $this->gitlab_app_id = $gitlab_app_id; + $gitlab_app = $this->getGitlabApp(); + + try { + $result = loadGitlabRepositories($gitlab_app, $this->page); + $this->repositories = $this->repositories->concat(collect($result['repositories'])); + + while ($result['has_more'] && $this->page < 50) { + $this->page++; + $result = loadGitlabRepositories($gitlab_app, $this->page); + $this->repositories = $this->repositories->concat(collect($result['repositories'])); + } + $this->total_repositories_count = $this->repositories->count(); + + $this->repositories = $this->repositories->sortBy('name'); + if ($this->repositories->count() > 0) { + $first = $this->repositories->first(); + $this->selected_project_id = data_get($first, 'id'); + $this->selected_repository_path = data_get($first, 'path_with_namespace'); + } + $this->current_step = 'repository'; + } catch (\Throwable $e) { + return $this->dispatch('error', $e->getMessage()); + } + } + + public function loadBranches() + { + $repo = $this->repositories->where('id', $this->selected_project_id)->first(); + $this->selected_repository_path = data_get($repo, 'path_with_namespace', $this->selected_repository_path); + $this->branches = collect(); + $this->page = 1; + $gitlab_app = $this->getGitlabApp(); + + try { + $branches = loadGitlabBranches($gitlab_app, $this->selected_project_id, $this->page); + $this->total_branches_count = count($branches); + $this->branches = $this->branches->concat(collect($branches)); + + while ($this->total_branches_count === 100) { + $this->page++; + $branches = loadGitlabBranches($gitlab_app, $this->selected_project_id, $this->page); + $this->total_branches_count = count($branches); + $this->branches = $this->branches->concat(collect($branches)); + } + + $this->branches = sortBranchesByPriority($this->branches); + $defaultBranch = data_get($repo, 'default_branch', 'main'); + $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) ? $defaultBranch : data_get($this->branches, '0.name', 'main'); + } catch (\Throwable $e) { + return $this->dispatch('error', $e->getMessage()); + } + } + + public function submit() + { + try { + $validator = validator([ + 'selected_repository_path' => $this->selected_repository_path, + 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, + ], [ + 'selected_repository_path' => 'required|string', + 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + 'docker_compose_location' => ValidationPatterns::filePathRules(), + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); + } + + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { + throw new \Exception('Destination not found.'); + } + $destination_class = $destination->getMorphClass(); + + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + + $gitlab_app = $this->getGitlabApp(); + + $application = Application::create([ + 'name' => generate_application_name($this->selected_repository_path, $this->selected_branch_name), + 'repository_project_id' => $this->selected_project_id, + 'git_repository' => $this->selected_repository_path, + 'git_branch' => str($this->selected_branch_name)->trim()->toString(), + 'build_pack' => $this->build_pack, + 'ports_exposes' => $this->port, + 'publish_directory' => $this->publish_directory, + 'base_directory' => $this->base_directory, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination_class, + 'source_id' => $gitlab_app->id, + 'source_type' => $gitlab_app->getMorphClass(), + ]); + $application->settings->is_static = $this->is_static; + $application->settings->save(); + + if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { + $application->health_check_enabled = false; + } + if ($this->build_pack === 'dockercompose') { + $application['docker_compose_location'] = $this->docker_compose_location; + } + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); + $application->fqdn = $fqdn; + $application->name = generate_application_name($this->selected_repository_path, $this->selected_branch_name, $application->uuid); + $application->save(); + + return redirect()->route('project.application.configuration', [ + 'application_uuid' => $application->uuid, + 'environment_uuid' => $environment->uuid, + 'project_uuid' => $project->uuid, + ]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + if ($this->is_static) { + $this->port = 80; + $this->publish_directory = '/dist'; + } else { + $this->port = 3000; + $this->publish_directory = null; + } + $this->dispatch('success', 'Application settings updated!'); + } +} diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 08047fc79..9bc2667ac 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -170,6 +170,12 @@ class Select extends Component 'description' => 'You can deploy public & private repositories through your GitHub Apps.', 'logo' => asset('svgs/github.svg'), ], + [ + 'id' => 'private-gitlab-app', + 'name' => 'Private Repository (with GitLab App)', + 'description' => 'You can deploy public & private repositories through your GitLab Apps.', + 'logo' => asset('svgs/gitlab.svg'), + ], [ 'id' => 'private-deploy-key', 'name' => 'Private Repository (with Deploy Key)', diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php new file mode 100644 index 000000000..0cb36a5f8 --- /dev/null +++ b/app/Livewire/Source/Gitlab/Change.php @@ -0,0 +1,278 @@ + 'required|string', + 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'clientId' => 'nullable|string', + 'clientSecretInput' => 'nullable|string', + 'webhookToken' => 'nullable|string', + 'groupName' => 'nullable|string', + 'isSystemWide' => 'required|bool', + 'privateKeyId' => 'nullable|int', + ]; + } + + public function mount() + { + try { + $gitlab_app_uuid = request()->gitlab_app_uuid; + $this->gitlab_app = GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->whereUuid($gitlab_app_uuid)->firstOrFail(); + + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); + $this->applications = $this->gitlab_app->applications; + + $settings = instanceSettings(); + + $this->syncData(false); + + $this->isConnected = $this->gitlab_app->isConnected(); + $this->fqdn = $settings->fqdn; + + if ($settings->public_ipv4) { + $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); + } + if ($settings->public_ipv6) { + $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + } + + $this->parameters = get_route_parameters(); + + if (isCloud() && ! isDev()) { + $this->webhook_endpoint = config('app.url'); + } else { + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? ''; + } + + $this->redirectUri = $this->webhook_endpoint.'/webhooks/source/gitlab/redirect'; + + $this->oauthState = $this->createOAuthState(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public static function oauthStateCacheKey(string $state): string + { + return 'gitlab-app-oauth-state:'.hash('sha256', $state); + } + + private function createOAuthState(): string + { + $state = Str::random(64); + + Cache::put(self::oauthStateCacheKey($state), [ + 'gitlab_app_id' => $this->gitlab_app->id, + 'team_id' => currentTeam()->id, + ], now()->addMinutes(60)); + + return $state; + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->gitlab_app->name = $this->name; + $this->gitlab_app->api_url = $this->apiUrl; + $this->gitlab_app->html_url = rtrim($this->htmlUrl, '/'); + $this->gitlab_app->custom_user = $this->customUser; + $this->gitlab_app->custom_port = $this->customPort; + $this->gitlab_app->client_id = $this->clientId; + if (! empty($this->clientSecretInput)) { + $this->gitlab_app->client_secret = $this->clientSecretInput; + } + $this->gitlab_app->webhook_token = $this->webhookToken; + $this->gitlab_app->group_name = $this->groupName; + $this->gitlab_app->is_system_wide = $this->isSystemWide; + $this->gitlab_app->private_key_id = $this->privateKeyId; + $this->gitlab_app->redirect_uri = $this->redirectUri; + } else { + $this->name = $this->gitlab_app->name; + $this->apiUrl = $this->gitlab_app->api_url; + $this->htmlUrl = $this->gitlab_app->html_url; + $this->customUser = $this->gitlab_app->custom_user; + $this->customPort = $this->gitlab_app->custom_port; + $this->clientId = $this->gitlab_app->client_id; + $this->clientSecretInput = null; + $this->webhookToken = $this->gitlab_app->webhook_token; + $this->groupName = $this->gitlab_app->group_name; + $this->isSystemWide = $this->gitlab_app->is_system_wide; + $this->privateKeyId = $this->gitlab_app->private_key_id; + } + } + + public function submit() + { + try { + $this->authorize('update', $this->gitlab_app); + + $this->validate(); + + $this->syncData(true); + $this->gitlab_app->save(); + $this->dispatch('success', 'GitLab App updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $this->authorize('update', $this->gitlab_app); + + $this->gitlab_app->makeVisible(['client_secret', 'webhook_token', 'access_token', 'refresh_token']); + $this->syncData(true); + $this->gitlab_app->save(); + $this->dispatch('success', 'GitLab App updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function testConnection() + { + try { + $this->authorize('view', $this->gitlab_app); + + if (! $this->gitlab_app->isConnected()) { + $this->dispatch('error', 'GitLab App is not connected. Please complete the OAuth flow first.'); + + return; + } + + refreshGitlabToken($this->gitlab_app); + + $apiUrl = $this->gitlab_app->apiUrlBase(); + $response = Http::GitLab($apiUrl, $this->gitlab_app->access_token) + ->timeout(10) + ->get('/user'); + + if ($response->successful()) { + $username = data_get($response->json(), 'username', 'unknown'); + $this->dispatch('success', "Connection successful! Authenticated as: {$username}"); + } else { + $error = data_get($response->json(), 'message', 'Unknown error'); + $this->dispatch('error', "Connection failed: {$error}"); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function disconnect() + { + try { + $this->authorize('update', $this->gitlab_app); + + $this->gitlab_app->update([ + 'access_token' => null, + 'refresh_token' => null, + 'expires_at' => null, + ]); + + $this->isConnected = false; + $this->dispatch('success', 'GitLab App disconnected.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function delete() + { + try { + $this->authorize('delete', $this->gitlab_app); + + if ($this->gitlab_app->applications->isNotEmpty()) { + $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); + + return; + } + $this->gitlab_app->delete(); + + return redirect()->route('source.all'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getOAuthUrl(): string + { + $baseUrl = rtrim($this->htmlUrl, '/'); + + $query = http_build_query([ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'response_type' => 'code', + 'scope' => 'api read_user read_repository', + 'state' => $this->oauthState ??= $this->createOAuthState(), + ]); + + return "{$baseUrl}/oauth/authorize?{$query}"; + } +} diff --git a/app/Livewire/Source/Gitlab/Create.php b/app/Livewire/Source/Gitlab/Create.php new file mode 100644 index 000000000..7f37219ec --- /dev/null +++ b/app/Livewire/Source/Gitlab/Create.php @@ -0,0 +1,62 @@ +name = substr(generate_random_name(), 0, 30); + } + + public function createGitLabApp() + { + try { + $this->authorize('createAnyResource'); + + $this->validate([ + 'name' => 'required|string', + 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'is_system_wide' => 'required|bool', + 'group_name' => 'nullable|string', + ]); + + $htmlUrl = rtrim($this->html_url, '/'); + $apiUrl = $htmlUrl.'/api/v4'; + + $gitlab_app = GitlabApp::create([ + 'name' => $this->name, + 'api_url' => $apiUrl, + 'html_url' => $htmlUrl, + 'is_system_wide' => $this->is_system_wide, + 'group_name' => $this->group_name, + 'webhook_token' => Str::random(32), + 'team_id' => currentTeam()->id, + ]); + + if (session('from')) { + session(['from' => session('from') + ['source_id' => $gitlab_app->id]]); + } + + return redirectRoute($this, 'source.gitlab.show', ['gitlab_app_uuid' => $gitlab_app->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 732142b0d..eff03b175 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1378,11 +1378,11 @@ class Application extends BaseModel if ($this->deploymentType() === 'source') { $source_html_url = data_get($this, 'source.html_url'); - $url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)); - $source_html_url_host = $url['host']; - $source_html_url_scheme = $url['scheme']; if ($this->source->getMorphClass() == 'App\Models\GithubApp') { + $url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: []; + $source_html_url_host = $url['host'] ?? ''; + $source_html_url_scheme = $url['scheme'] ?? ''; $escapedCustomRepository = escapeshellarg($customRepository); if ($this->source->is_public) { $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); @@ -1420,6 +1420,32 @@ class Application extends BaseModel if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; + + if ($gitlabSource->isConnected()) { + $url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: []; + $source_html_url_host = $this->urlHostWithPort($url); + $source_html_url_scheme = $url['scheme'] ?? ''; + $token = generateGitlabCloneToken($gitlabSource); + $encodedToken = rawurlencode($token); + $pathPrefix = rtrim($url['path'] ?? '', '/'); + $repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $fullRepoUrl = $repoUrl; + $base_command = "{$base_command} {$escapedRepoUrl}"; + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + $private_key = data_get($gitlabSource, 'privateKey.private_key'); if ($private_key) { @@ -1444,7 +1470,6 @@ class Application extends BaseModel ]; } - // GitLab source without private key — use URL as-is (supports user-embedded basic auth) $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $base_command = "{$base_command} {$escapedCustomRepository}"; @@ -1677,6 +1702,52 @@ class Application extends BaseModel if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; + + if ($gitlabSource->isConnected()) { + $token = generateGitlabCloneToken($gitlabSource); + $encodedToken = rawurlencode($token); + $pathPrefix = rtrim($url['path'] ?? '', '/'); + $source_html_url_host = $this->urlHostWithPort($url ?: []); + + // Rewrite same-host HTTPS submodule URLs to auth with the OAuth token (mirrors the GitHub path) without persisting credentials. + $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}{$pathPrefix}/"); + $gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption); + + $repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $fullRepoUrl = $repoUrl; + $git_clone_command_base = $this->applyGitConfigOptionsToCloneCommand("{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}", $gitConfigOptions); + if ($only_checkout) { + $git_clone_command = $git_clone_command_base; + } else { + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions); + } + + if ($pull_request_id !== 0) { + $branch = "merge-requests/{$pull_request_id}/head:{$pr_branch_name}"; + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out {$branch}'")); + } else { + $commands->push("echo 'Checking out {$branch}'"); + } + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions); + $escapedPrBranch = escapeshellarg($branch); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOptions} fetch origin {$escapedPrBranch} && {$git_checkout_command}"; + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + $private_key = data_get($gitlabSource, 'privateKey.private_key'); if ($private_key) { @@ -1717,7 +1788,6 @@ class Application extends BaseModel ]; } - // GitLab source without private key — use URL as-is (supports user-embedded basic auth) $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; @@ -2382,6 +2452,14 @@ class Application extends BaseModel ]; } + private function urlHostWithPort(array $url): string + { + $host = $url['host'] ?? ''; + $port = isset($url['port']) ? ":{$url['port']}" : ''; + + return "{$host}{$port}"; + } + public function generateConfig($is_json = false) { $generator = new ConfigurationGenerator($this); diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 06df8fd8d..2279fe641 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -16,20 +16,68 @@ class GitlabApp extends BaseModel 'app_id', 'app_secret', 'oauth_id', + 'client_id', + 'client_secret', + 'access_token', + 'refresh_token', + 'expires_at', + 'redirect_uri', 'group_name', 'public_key', 'webhook_token', 'deploy_key_id', + 'private_key_id', + 'team_id', ]; protected $hidden = [ 'webhook_token', 'app_secret', + 'client_secret', + 'access_token', + 'refresh_token', ]; + protected function casts(): array + { + return [ + 'access_token' => 'encrypted', + 'refresh_token' => 'encrypted', + 'client_secret' => 'encrypted', + 'is_system_wide' => 'boolean', + 'is_public' => 'boolean', + ]; + } + + protected static function booted(): void + { + static::deleting(function (GitlabApp $gitlabApp) { + if ($gitlabApp->applications()->count() > 0) { + throw new \RuntimeException('This source is being used by an application. Please delete all applications first.'); + } + }); + } + public static function ownedByCurrentTeam() { - return GitlabApp::whereTeamId(currentTeam()->id); + return GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id) + ->orWhere('is_system_wide', true); + }); + } + + public static function public() + { + return GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->where('is_public', true); + } + + public static function private() + { + return GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->where('is_public', false)->whereNotNull('access_token'); } public function applications() @@ -41,4 +89,25 @@ class GitlabApp extends BaseModel { return $this->belongsTo(PrivateKey::class); } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function isConnected(): bool + { + return ! empty($this->access_token) && ! empty($this->refresh_token); + } + + public function apiUrlBase(): string + { + $apiUrl = rtrim($this->api_url, '/'); + + if (! str_contains($apiUrl, '/api/v4')) { + $apiUrl .= '/api/v4'; + } + + return $apiUrl; + } } diff --git a/app/Policies/GitlabAppPolicy.php b/app/Policies/GitlabAppPolicy.php new file mode 100644 index 000000000..596e7e15a --- /dev/null +++ b/app/Policies/GitlabAppPolicy.php @@ -0,0 +1,52 @@ +is_system_wide) { + return true; + } + + return true; + } + + public function delete(User $user, GitlabApp $gitlabApp): bool + { + if ($gitlabApp->is_system_wide) { + return true; + } + + return true; + } + + public function restore(User $user, GitlabApp $gitlabApp): bool + { + return false; + } + + public function forceDelete(User $user, GitlabApp $gitlabApp): bool + { + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 580aaa078..c76d808a3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -102,5 +102,16 @@ class AppServiceProvider extends ServiceProvider ])->baseUrl($api_url); } }); + + Http::macro('GitLab', function (string $api_url, ?string $access_token = null) { + $client = Http::withHeaders([ + 'Accept' => 'application/json', + ])->baseUrl($api_url); + if ($access_token) { + $client = $client->withToken($access_token); + } + + return $client; + }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 8903e750b..7bc8f162d 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -13,6 +13,7 @@ use App\Models\EmailNotificationSettings; use App\Models\Environment; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\PrivateKey; use App\Models\Project; @@ -51,6 +52,7 @@ use App\Policies\DatabasePolicy; use App\Policies\EnvironmentPolicy; use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; +use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; @@ -127,6 +129,9 @@ class AuthServiceProvider extends ServiceProvider // Git source policies GithubApp::class => GithubAppPolicy::class, + GitlabApp::class => GitlabAppPolicy::class, + + // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 465500b06..66fe0c233 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -1,7 +1,6 @@ refresh_token) { + throw new RuntimeException('GitLab source has no refresh token. Please reconnect the GitLab app.'); + } + + $safetyMargin = 60; + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + + $lock = Cache::lock("gitlab_token_refresh_{$source->id}", 20); + if (! $lock->block(20)) { + $source->refresh(); + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + throw new RuntimeException('GitLab token refresh timed out. Please try again.'); + } + + try { + $source->refresh(); + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + + $baseUrl = rtrim($source->html_url, '/'); + + $response = Http::asForm()->post("{$baseUrl}/oauth/token", [ + 'client_id' => $source->client_id, + 'client_secret' => $source->client_secret, + 'refresh_token' => $source->refresh_token, + 'grant_type' => 'refresh_token', + 'redirect_uri' => $source->redirect_uri, + ]); + + if (! $response->successful()) { + $error = data_get($response->json(), 'error_description', $response->body()); + throw new RuntimeException("Failed to refresh GitLab token: {$error}"); + } + + $data = $response->json(); + $source->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'], + 'expires_at' => time() + ($data['expires_in'] ?? 7200), + ]); + } finally { + $lock->release(); + } +} + +function gitlabApi(GitlabApp $source, string $endpoint, string $method = 'get', ?array $data = null): array +{ + refreshGitlabToken($source); + + $apiUrl = $source->apiUrlBase(); + + $client = Http::GitLab($apiUrl, $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false); + + if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) { + $response = $client->$method($endpoint, $data); + } else { + $response = $client->$method($endpoint); + } + + if (! $response->successful()) { + $errorMessage = data_get($response->json(), 'message', $response->body()); + throw new RuntimeException("GitLab API call failed: {$errorMessage}"); + } + + return [ + 'data' => collect($response->json()), + 'total' => (int) $response->header('x-total', 0), + ]; +} + +function generateGitlabCloneToken(GitlabApp $source): string +{ + refreshGitlabToken($source); + + return $source->access_token; +} + +function loadGitlabRepositories(GitlabApp $source, int $page = 1): array +{ + refreshGitlabToken($source); + + $apiUrl = $source->apiUrlBase(); + $response = Http::GitLab($apiUrl, $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get('/projects', [ + 'membership' => 'true', + 'per_page' => 100, + 'page' => $page, + 'order_by' => 'name', + 'sort' => 'asc', + ]); + + if (! $response->successful()) { + return ['total_count' => 0, 'has_more' => false, 'repositories' => []]; + } + + $projects = collect($response->json()); + $rawCount = count($response->json()); + $hasMore = $rawCount === 100; + + $groupName = $source->group_name; + if (! empty($groupName)) { + $groups = collect(explode(',', $groupName))->map(fn ($g) => strtolower(trim($g)))->filter(); + $projects = $projects->filter(function ($project) use ($groups) { + $namespacePath = strtolower(data_get($project, 'namespace.full_path', '')); + + return $groups->contains(fn ($group) => $namespacePath === $group || str_starts_with($namespacePath, $group.'/')); + }); + } + + return [ + 'total_count' => $projects->count(), + 'has_more' => $hasMore, + 'repositories' => $projects->map(fn ($project) => [ + 'id' => data_get($project, 'id'), + 'name' => data_get($project, 'name'), + 'path_with_namespace' => data_get($project, 'path_with_namespace'), + 'default_branch' => data_get($project, 'default_branch', 'main'), + 'web_url' => data_get($project, 'web_url'), + 'namespace' => [ + 'full_path' => data_get($project, 'namespace.full_path'), + 'kind' => data_get($project, 'namespace.kind'), + ], + ])->values()->all(), + ]; +} + +function loadGitlabBranches(GitlabApp $source, int $projectId, int $page = 1): array +{ + refreshGitlabToken($source); + + $apiUrl = $source->apiUrlBase(); + $response = Http::GitLab($apiUrl, $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/projects/{$projectId}/repository/branches", [ + 'per_page' => 100, + 'page' => $page, + ]); + + if (! $response->successful()) { + return []; + } + + return $response->json(); +} diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 0f3a7c4dd..84522a5e1 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -301,6 +301,7 @@ function remove_iip($text) // Git access tokens $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); + $text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text); // ANSI color codes $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); diff --git a/database/migrations/2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table.php b/database/migrations/2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table.php new file mode 100644 index 000000000..ec2ae6982 --- /dev/null +++ b/database/migrations/2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table.php @@ -0,0 +1,34 @@ +longText('client_id')->nullable()->after('app_secret'); + $table->longText('client_secret')->nullable()->after('client_id'); + $table->longText('access_token')->nullable()->after('client_secret'); + $table->longText('refresh_token')->nullable()->after('access_token'); + $table->integer('expires_at')->nullable()->after('refresh_token'); + $table->string('redirect_uri')->nullable()->after('expires_at'); + }); + } + + public function down(): void + { + Schema::table('gitlab_apps', function (Blueprint $table) { + $table->dropColumn([ + 'client_id', + 'client_secret', + 'access_token', + 'refresh_token', + 'expires_at', + 'redirect_uri', + ]); + }); + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql index 4a73bf37d..b1d1f4a3a 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -370,6 +370,12 @@ CREATE TABLE IF NOT EXISTS "gitlab_apps" ( "is_public" INTEGER DEFAULT false NOT NULL, "app_id" INTEGER, "app_secret" TEXT, + "client_id" TEXT, + "client_secret" TEXT, + "access_token" TEXT, + "refresh_token" TEXT, + "expires_at" INTEGER, + "redirect_uri" TEXT, "oauth_id" INTEGER, "group_name" TEXT, "public_key" TEXT, @@ -1898,6 +1904,7 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1 INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (315, '2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table', 315); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318); diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index 1e624738c..6e8d791de 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -11,7 +11,7 @@ Open Repository - @if (data_get($application, 'source.is_public') === false) + @if (data_get($application, 'source.is_public') === false && $application->source instanceof \App\Models\GithubApp) Open Git App @@ -67,7 +67,7 @@ @forelse ($sources as $source)
{{ $source->name }} - @if ($application->source_id === $source->id) + @if ($application->source_id === $source->id && $application->source_type === $source->getMorphClass()) (current) @endif
diff --git a/resources/views/livewire/project/new/gitlab-private-repository.blade.php b/resources/views/livewire/project/new/gitlab-private-repository.blade.php new file mode 100644 index 000000000..e51e623fe --- /dev/null +++ b/resources/views/livewire/project/new/gitlab-private-repository.blade.php @@ -0,0 +1,152 @@ +
+
+

Create a new Application

+ + + + @if ($repositories->count() > 0 && $gitlab_app_id) + + Refresh Repository List + + @endif +
+
Deploy any public or private Git repositories through a GitLab App.
+ @if ($gitlab_apps->count() !== 0) +
+ @if ($current_step === 'gitlab_apps') +

Select a GitLab App

+
+ @foreach ($gitlab_apps as $glapp) +
+
+
+
+
+ {{ data_get($glapp, 'name') }} +
+
+ {{ data_get($glapp, 'html_url') }}
+
+
+
+
+ +
+
+ @endforeach +
+ @endif + @if ($current_step === 'repository') + @if ($repositories->count() > 0) +
+
+ + @foreach ($repositories as $repo) + + @endforeach + +
+ + Load Repository + + +
+ @else +
No repositories found. Check your GitLab App configuration.
+ @endif + @if ($branches->count() > 0) +

Configuration

+
+
+
+
+ + + @foreach ($branches as $branch) + @if ($loop->first) + + @else + + @endif + @endforeach + + + + + + + + + @if ($is_static) + + @endif +
+ @if ($build_pack === 'dockercompose') +
+ + +
+ + Compose file location in your repository: +
+
+ @else + + @endif + @if ($show_is_static) + +
+ +
+ @endif +
+ + Continue + +
+
+ @endif + @endif +
+ @else +
+ @endif +
diff --git a/resources/views/livewire/project/resource/create.blade.php b/resources/views/livewire/project/resource/create.blade.php index f5861b35b..2821dbfc2 100644 --- a/resources/views/livewire/project/resource/create.blade.php +++ b/resources/views/livewire/project/resource/create.blade.php @@ -6,6 +6,8 @@ @elseif ($type === 'private-gh-app') + @elseif ($type === 'private-gitlab-app') + @elseif ($type === 'private-deploy-key') @elseif ($type === 'dockerfile') diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php new file mode 100644 index 000000000..3072f457d --- /dev/null +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -0,0 +1,175 @@ +
+ @if ($isConnected) +
+
+

GitLab App

+
+ Save + Test Connection + @can('delete', $gitlab_app) + + @endcan +
+
+
Your GitLab App for private repositories.
+
+ + + Connected + + + Disconnect + +
+
+ +
+ + +
+ +
+ + +
+ @if (!isCloud()) +
+ +
+ @endif + +

OAuth Credentials

+ + + +

Webhook

+
+
+ Configure this webhook URL in your GitLab project settings + (Settings > Webhooks): +
+ + +
+ +

SSH Key (Optional)

+
+ Only needed if you prefer SSH-based git clone over HTTPS OAuth token. +
+
+ + + @foreach ($privateKeys as $key) + + @endforeach + +
+ + @if ($applications->count() > 0) +

Applications Using This Source

+ + @endif +
+
+ @else +
+

GitLab App

+
+ @can('delete', $gitlab_app) + + @endcan +
+
+
Connect your GitLab instance to deploy private repositories.
+ +
+ + + + Complete the setup below to connect this GitLab source. +
+ +
+

Step 1: Create an OAuth Application on GitLab

+
+

Go to your GitLab instance and create a new OAuth Application:

+ + {{ rtrim($htmlUrl, '/') }}/-/profile/applications + + +
    +
  • Set Redirect URI to: {{ $redirectUri }}
  • +
  • Enable scopes: api, read_user, read_repository
  • +
  • Uncheck Confidential if you run into issues
  • +
+
+ +

Step 2: Enter the credentials

+
+ +
+ + +
+ + + +
+ + +
+ @if (!isCloud()) + + @endif + Save Credentials + + + @if ($clientId) +

Step 3: Authorize with GitLab

+
Click the button below to authorize Coolify with your GitLab instance.
+ + + Connect to GitLab + + + @endif +
+ @endif +
diff --git a/resources/views/livewire/source/gitlab/create.blade.php b/resources/views/livewire/source/gitlab/create.blade.php new file mode 100644 index 000000000..61e2bedeb --- /dev/null +++ b/resources/views/livewire/source/gitlab/create.blade.php @@ -0,0 +1,18 @@ +
+
+
+

New GitLab App

+ Save +
+
Add a self-hosted or GitLab.com instance as a source for your applications.
+ + + + @if (!isCloud()) + + @endif + +
diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index c6566d2f9..b5f5d5e99 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -5,9 +5,12 @@

Sources

@can('createAnyResource') - + + + + @endcan
Git sources for your applications.
@@ -17,7 +20,6 @@ - {{-- --}}
{{ $source->name }}
@if (is_null($source->app_id)) @@ -29,6 +31,22 @@ @endif
+ @elseif ($source->getMorphClass() === 'App\Models\GitlabApp') + +
+
+ + {{ $source->name }} +
+ @if ($source->isConnected()) + Connected — {{ $source->html_url }} + @else + Setup required — {{ $source->html_url }} + @endif +
+
@endif @empty
diff --git a/routes/web.php b/routes/web.php index c26b3eb15..6a212725f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -83,6 +83,7 @@ use App\Livewire\SharedVariables\Server\Index as ServerSharedVariablesIndex; use App\Livewire\SharedVariables\Server\Show as ServerSharedVariablesShow; use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex; use App\Livewire\Source\Github\Change as GitHubChange; +use App\Livewire\Source\Gitlab\Change as GitLabChange; use App\Livewire\Storage\Index as StorageIndex; use App\Livewire\Storage\Show as StorageShow; use App\Livewire\Subscription\Index as SubscriptionIndex; @@ -353,6 +354,7 @@ Route::middleware(['auth'])->group(function () { Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show'); Route::get('/source/github/{github_app_uuid}/permissions', GitHubChange::class)->name('source.github.permissions'); Route::get('/source/github/{github_app_uuid}/resources', GitHubChange::class)->name('source.github.resources'); + Route::get('/source/gitlab/{gitlab_app_uuid}', GitLabChange::class)->name('source.gitlab.show'); }); Route::middleware(['auth'])->group(function () { diff --git a/routes/webhooks.php b/routes/webhooks.php index 804fd7bcb..27796b807 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Route; Route::middleware(['web', 'auth', 'throttle:30,1'])->group(function () { Route::get('/source/github/redirect', [Github::class, 'redirect']); Route::get('/source/github/install', [Github::class, 'install']); + Route::get('/source/gitlab/redirect', [Gitlab::class, 'redirect']); }); Route::post('/source/github/events', [Github::class, 'normal']); Route::post('/source/github/events/manual', [Github::class, 'manual']); +Route::post('/source/gitlab/events', [Gitlab::class, 'normal']); Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']); Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']); diff --git a/tests/Feature/ApplicationSourceTypeFilterTest.php b/tests/Feature/ApplicationSourceTypeFilterTest.php new file mode 100644 index 000000000..a4ff86d4f --- /dev/null +++ b/tests/Feature/ApplicationSourceTypeFilterTest.php @@ -0,0 +1,73 @@ +id = 0; + $settings->save(); + } + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('a GitLab source is not hidden by a GitHub source sharing the same numeric id', function () { + $githubApp = GithubApp::create([ + 'name' => 'gh', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'app_id' => 123, + ]); + + $gitlabApp = GitlabApp::create([ + 'name' => 'gl', + 'team_id' => $this->team->id, + 'api_url' => 'https://gitlab.example.test/api/v4', + 'html_url' => 'https://gitlab.example.test', + 'is_public' => false, + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_at' => time() + 3600, + ]); + + // The two source tables auto-increment independently, so the first row in each shares id 1. + expect($gitlabApp->id)->toBe($githubApp->id); + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => null, + 'source_id' => $githubApp->id, + 'source_type' => GithubApp::class, + ]); + + $component = Livewire::test(Source::class, ['application' => $application]); + $sources = $component->get('sources'); + + expect($sources->contains(fn ($s) => $s instanceof GitlabApp && $s->id === $gitlabApp->id))->toBeTrue(); + expect($sources->contains(fn ($s) => $s instanceof GithubApp && $s->id === $githubApp->id))->toBeFalse(); + + // The GitLab source renders as selectable, not flagged as the current GitHub source despite the shared id. + $component->assertSee('gl')->assertDontSee('(current)'); +}); diff --git a/tests/Feature/GitlabOAuthCallbackStateTest.php b/tests/Feature/GitlabOAuthCallbackStateTest.php new file mode 100644 index 000000000..2bd8e2aa8 --- /dev/null +++ b/tests/Feature/GitlabOAuthCallbackStateTest.php @@ -0,0 +1,108 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->gitlabApp = GitlabApp::create([ + 'name' => 'Self-hosted GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitlab/redirect', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +describe('GitLab OAuth callback state validation', function () { + test('rejects a callback whose state is the source UUID (the old attack vector)', function () { + Http::fake(); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$this->gitlabApp->uuid); + + $response->assertRedirect(route('source.all')); + Http::assertNothingSent(); + expect($this->gitlabApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a callback with an unknown / expired state and never exchanges the code', function () { + Http::fake(); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=not-a-real-state'); + + $response->assertRedirect(route('source.all')); + Http::assertNothingSent(); + expect($this->gitlabApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a state issued for a different team', function () { + Http::fake(); + $otherTeam = Team::factory()->create(); + $state = 'state-for-other-team'; + Cache::put(GitlabSource::oauthStateCacheKey($state), [ + 'gitlab_app_id' => $this->gitlabApp->id, + 'team_id' => $otherTeam->id, + ], now()->addMinutes(60)); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$state); + + $response->assertRedirect(route('source.all')); + Http::assertNothingSent(); + expect($this->gitlabApp->refresh()->access_token)->toBeNull(); + }); + + test('accepts a valid one-time state, exchanges the code, and consumes the state', function () { + Http::fake([ + '*/oauth/token' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 7200, + ]), + ]); + + $state = 'a-valid-server-issued-state'; + $key = GitlabSource::oauthStateCacheKey($state); + Cache::put($key, [ + 'gitlab_app_id' => $this->gitlabApp->id, + 'team_id' => $this->team->id, + ], now()->addMinutes(60)); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=valid-code&state='.$state); + + $response->assertRedirect(route('source.gitlab.show', ['gitlab_app_uuid' => $this->gitlabApp->uuid])); + + $fresh = $this->gitlabApp->refresh(); + $fresh->makeVisible(['access_token', 'refresh_token']); + expect($fresh->access_token)->toBe('new-access-token'); + expect($fresh->refresh_token)->toBe('new-refresh-token'); + + // State must be single-use. + expect(Cache::get($key))->toBeNull(); + }); + + test('requires authentication', function () { + auth()->logout(); + session()->forget('currentTeam'); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=any'); + + $response->assertRedirect(route('login')); + }); +}); diff --git a/tests/Feature/GitlabRepositoryListingTest.php b/tests/Feature/GitlabRepositoryListingTest.php new file mode 100644 index 000000000..f42263b9d --- /dev/null +++ b/tests/Feature/GitlabRepositoryListingTest.php @@ -0,0 +1,52 @@ + Http::response(['message' => '401 Unauthorized'], 401)]); + + $source = new GitlabApp([ + 'api_url' => 'https://gitlab.example.test/api/v4', + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => str_repeat('r', 20), // ggignore + 'expires_at' => time() + 3600, + ]); + + $result = loadGitlabRepositories($source); + + expect($result)->toHaveKeys(['total_count', 'has_more', 'repositories']); + expect($result['has_more'])->toBeFalse(); + expect($result['repositories'])->toBe([]); +}); + +it('limits GitLab repositories to the exact group and its descendants', function () { + Http::fake(['*' => Http::response([ + ['id' => 1, 'name' => 'a', 'path_with_namespace' => 'team/a', 'namespace' => ['full_path' => 'team', 'kind' => 'group']], + ['id' => 2, 'name' => 'b', 'path_with_namespace' => 'team/sub/b', 'namespace' => ['full_path' => 'team/sub', 'kind' => 'group']], + ['id' => 3, 'name' => 'c', 'path_with_namespace' => 'team-secret/c', 'namespace' => ['full_path' => 'team-secret', 'kind' => 'group']], + ], 200)]); + + $source = new GitlabApp([ + 'api_url' => 'https://gitlab.example.test/api/v4', + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => str_repeat('r', 20), // ggignore + 'expires_at' => time() + 3600, + 'group_name' => 'team', + ]); + + $paths = collect(loadGitlabRepositories($source)['repositories'])->pluck('path_with_namespace')->all(); + + expect($paths)->toContain('team/a', 'team/sub/b'); + expect($paths)->not->toContain('team-secret/c'); +}); diff --git a/tests/Feature/GitlabSystemWideSourceTest.php b/tests/Feature/GitlabSystemWideSourceTest.php new file mode 100644 index 000000000..05aab8762 --- /dev/null +++ b/tests/Feature/GitlabSystemWideSourceTest.php @@ -0,0 +1,51 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('ownedByCurrentTeam resolves a system-wide GitLab source owned by another team', function () { + $otherTeam = Team::factory()->create(); + + $systemWide = GitlabApp::create([ + 'name' => 'Shared GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + // Mirrors Source::changeSource() resolution; before the fix this returned null for system-wide sources (404). + $resolved = GitlabApp::ownedByCurrentTeam()->find($systemWide->id); + + expect($resolved)->not->toBeNull(); + expect($resolved->id)->toBe($systemWide->id); +}); + +test('ownedByCurrentTeam still excludes other teams private (non system-wide) sources', function () { + $otherTeam = Team::factory()->create(); + + $foreign = GitlabApp::create([ + 'name' => 'Foreign GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'team_id' => $otherTeam->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + expect(GitlabApp::ownedByCurrentTeam()->find($foreign->id))->toBeNull(); +}); diff --git a/tests/Unit/GitlabAppSubmoduleCredentialsTest.php b/tests/Unit/GitlabAppSubmoduleCredentialsTest.php new file mode 100644 index 000000000..bf713070f --- /dev/null +++ b/tests/Unit/GitlabAppSubmoduleCredentialsTest.php @@ -0,0 +1,56 @@ +forceFill([ + 'uuid' => 'test-app-uuid', + 'git_repository' => 'group/private-app', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + $application->setRelation('settings', $settings); + + $source = new GitlabApp; + $source->forceFill([ + 'html_url' => 'https://gitlab.example.test', + 'api_url' => 'https://gitlab.example.test/api/v4', + 'is_public' => false, + ]); + // A non-expired token short-circuits refreshGitlabToken(), so no HTTP call is made. + $source->access_token = 'gl-token/with+sym'; + $source->refresh_token = 'gl-refresh-token'; + $source->expires_at = time() + 3600; + $application->setRelation('source', $source); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + exec_in_docker: false, + ); + + $expectedConfig = "git -c 'url.https://oauth2:gl-token%2Fwith%2Bsym@gitlab.example.test/.insteadOf=https://gitlab.example.test/' -c http.version=HTTP/1.1"; + + expect($result['commands']) + ->not->toContain('git config --global') + ->toContain("{$expectedConfig} clone --recurse-submodules -b 'main'") + ->toContain("{$expectedConfig} submodule sync") + ->toContain("{$expectedConfig} submodule update --init --recursive"); +}); diff --git a/tests/Unit/GitlabHelperTest.php b/tests/Unit/GitlabHelperTest.php new file mode 100644 index 000000000..bac21b205 --- /dev/null +++ b/tests/Unit/GitlabHelperTest.php @@ -0,0 +1,52 @@ + 'https://gitlab.example.com']); + expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4'); +}); + +it('returns api base url unchanged when /api/v4 already present', function () { + $app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4']); + expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4'); +}); + +it('strips trailing slash from api url base', function () { + $app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4/']); + expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4'); +}); + +it('reports connected when tokens are present', function () { + $app = new GitlabApp([ + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => str_repeat('r', 20), // ggignore + ]); + expect($app->isConnected())->toBeTrue(); +}); + +it('reports not connected when tokens are missing', function () { + $app = new GitlabApp([ + 'access_token' => null, + 'refresh_token' => null, + ]); + expect($app->isConnected())->toBeFalse(); +}); + +it('reports not connected when only access token is present', function () { + $app = new GitlabApp([ + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => null, + ]); + expect($app->isConnected())->toBeFalse(); +}); diff --git a/tests/Unit/GitlabOAuthRedactionTest.php b/tests/Unit/GitlabOAuthRedactionTest.php new file mode 100644 index 000000000..e5a4d6569 --- /dev/null +++ b/tests/Unit/GitlabOAuthRedactionTest.php @@ -0,0 +1,27 @@ +not->toContain($fakeOAuthToken); + expect($result)->toContain('oauth2:'); +}); + +it('redacts x-access-token from logs', function () { + $fakeToken = str_repeat('x', 40); // ggignore + $text = "git clone https://x-access-token:{$fakeToken}@github.com/org/repo.git /app"; + $result = remove_iip($text); + + expect($result)->not->toContain($fakeToken); + expect($result)->toContain('x-access-token:'); +}); + +it('redacts gitlab personal access tokens', function () { + $fakeToken = 'glpat-'.str_repeat('y', 20); // ggignore + $text = "Authorization: Bearer {$fakeToken}"; + $result = remove_iip($text); + + expect($result)->not->toContain($fakeToken); +}); diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php index 129a86506..e27236a8f 100644 --- a/tests/Unit/GitlabSourceCommandsTest.php +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -3,9 +3,16 @@ use App\Models\Application; use App\Models\GitlabApp; use App\Models\PrivateKey; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Encryption\Encrypter; use Illuminate\Support\Collection; +beforeEach(function () { + Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC')); +}); + afterEach(function () { + Model::encryptUsing(null); Mockery::close(); }); @@ -54,6 +61,9 @@ it('generates ls-remote commands for GitLab source with private key', function ( $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1); $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null); + $gitlabSource->shouldReceive('isConnected')->andReturn(false); $application = Mockery::mock(Application::class)->makePartial(); $application->git_branch = 'main'; @@ -83,6 +93,9 @@ it('generates ls-remote commands for GitLab source without private key', functio $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null); + $gitlabSource->shouldReceive('isConnected')->andReturn(false); $application = Mockery::mock(Application::class)->makePartial(); $application->git_branch = 'main'; @@ -112,6 +125,9 @@ it('does not return null for GitLab source type', function () { $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null); + $gitlabSource->shouldReceive('isConnected')->andReturn(false); $application = Mockery::mock(Application::class)->makePartial(); $application->git_branch = 'main'; @@ -127,3 +143,73 @@ it('does not return null for GitLab source type', function () { expect($lsRemoteResult)->not->toBeNull(); expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']); }); + +it('preserves custom GitLab http port for connected OAuth sources', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = new GitlabApp([ + 'html_url' => 'http://gitlab.example.test:8081', + 'access_token' => 'gitlab-access-token', + 'refresh_token' => 'gitlab-refresh-token', + 'expires_at' => time() + 3600, + ]); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'root/qa-private-app', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result['fullRepoUrl']) + ->toContain('gitlab.example.test:8081') + ->toBe('http://oauth2:gitlab-access-token@gitlab.example.test:8081/root/qa-private-app.git'); + expect($result['commands'])->toContain('gitlab.example.test:8081/root/qa-private-app.git'); +}); + +it('applies OAuth git config to GitLab merge-request fetch and submodule checkout', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = new GitlabApp([ + 'html_url' => 'https://gitlab.example.test', + 'access_token' => 'gitlab-access-token', + 'refresh_token' => 'gitlab-refresh-token', + 'expires_at' => time() + 3600, + ]); + + $settings = (object) [ + 'is_git_shallow_clone_enabled' => false, + 'is_git_submodules_enabled' => true, + ]; + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'root/qa-private-app', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->source = $gitlabSource; + + $result = $application->generateGitImportCommands( + deployment_uuid: $deploymentUuid, + pull_request_id: 2, + exec_in_docker: false, + only_checkout: true, + custom_base_dir: '/artifacts/test', + ); + + $commands = $result['commands']; + // The MR-ref fetch and submodule update must run through the OAuth-rewritten git, or private same-host submodules fail. + expect($commands) + ->toContain('oauth2:gitlab-access-token@gitlab.example.test') + ->toContain("http.version=HTTP/1.1 fetch origin 'merge-requests/2/head:pr-2-coolify'") + ->toContain('http.version=HTTP/1.1 submodule update --init --recursive'); +});