From 3c8518737775dacbba2809876a06fb3e2bb1a737 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:47:15 +0100 Subject: [PATCH] fix(github): use the owner/repo slug for GitHub API calls with SSH repository URLs Closes #10175 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/ApplicationDeploymentJob.php | 21 ++--- app/Jobs/ApplicationPullRequestUpdateJob.php | 6 +- app/Livewire/Project/Application/Previews.php | 2 +- app/Livewire/Project/Application/Source.php | 7 +- .../GithubRepositoryProjectIdSshUrlTest.php | 77 +++++++++++++++++++ 5 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 tests/Feature/GithubRepositoryProjectIdSshUrlTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 0eb4b3cd00..944f9b3bb5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3348,18 +3348,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } + private function update_repository_project_id(): void + { + $repositorySlug = gitRepositorySlug($this->customRepository); + $repository = githubApi($this->source, "repos/{$repositorySlug}"); + $repository_project_id = data_get($repository, '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(); + } + } + private function check_git_if_build_needed() { if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { - $repository = githubApi($this->source, "repos/{$this->customRepository}"); - $data = data_get($repository, 'data'); - $repository_project_id = data_get($data, 'id'); - if (isset($repository_project_id)) { - if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) { - $this->application->repository_project_id = $repository_project_id; - $this->application->save(); - } - } + $this->update_repository_project_id(); } $this->resolve_bitbucket_pull_request_commit(); $this->generate_git_import_commands(); diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index 025daa12b0..8a7766123f 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -70,7 +70,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue private function update_comment() { - ['data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [ + ['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [ 'body' => $this->body, ], throwError: false); if (data_get($data, 'message') === 'Not Found') { @@ -80,7 +80,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue private function create_comment() { - ['data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/{$this->preview->pull_request_id}/comments", method: 'post', data: [ + ['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/{$this->preview->pull_request_id}/comments", method: 'post', data: [ 'body' => $this->body, ]); $this->preview->pull_request_issue_comment_id = $data['id']; @@ -89,7 +89,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue private function delete_comment() { - githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete'); + githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete'); } private function getPreviewLinks(): string diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 7de6552b94..6ae092610f 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -98,7 +98,7 @@ class Previews extends Component if (! $this->application->isGithubAppSource()) { throw new \Exception('Loading pull requests is only supported for GitHub App sources.'); } - ['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls"); + ['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository).'/pulls'); $this->rate_limit_remaining = $rate_limit_remaining; $this->pull_requests = $data->sortBy('number')->values(); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index d2843a6780..395ddbfa39 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -163,20 +163,21 @@ class Source extends Component $this->dispatch('configurationChanged'); ['repository' => $customRepository] = $this->application->customRepository(); + $repositorySlug = gitRepositorySlug($customRepository); $repository_project_id = null; if ($sourceType === GithubApp::class) { - $repository = githubApi($source, "repos/{$customRepository}"); + $repository = githubApi($source, "repos/{$repositorySlug}"); $repository_project_id = data_get($repository, 'data.id'); } elseif ($sourceType === GitlabApp::class) { if ($source->isConnected()) { - $encoded = urlencode($customRepository); + $encoded = urlencode($repositorySlug); $project = gitlabApi($source, "/projects/{$encoded}"); $repository_project_id = data_get($project, 'data.id'); } } elseif ($sourceType === GiteaApp::class) { if ($source->isConnected()) { - $repositoryPath = collect(explode('/', $customRepository, 2))->map(fn ($part) => rawurlencode($part))->implode('/'); + $repositoryPath = collect(explode('/', $repositorySlug, 2))->map(fn ($part) => rawurlencode($part))->implode('/'); $repository = giteaApi($source, "/repos/{$repositoryPath}"); $repository_project_id = data_get($repository, 'data.id'); } diff --git a/tests/Feature/GithubRepositoryProjectIdSshUrlTest.php b/tests/Feature/GithubRepositoryProjectIdSshUrlTest.php new file mode 100644 index 0000000000..8cb2660e9f --- /dev/null +++ b/tests/Feature/GithubRepositoryProjectIdSshUrlTest.php @@ -0,0 +1,77 @@ + 0]); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + // A public GitHub App skips installation token generation, so only the repository lookup is faked. + $this->githubApp = GithubApp::create([ + 'name' => 'own-github-app', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'git_repository' => 'git@github.com:acme/shop.git', + 'source_id' => null, + 'source_type' => null, + ]); + + Http::fake([ + 'https://api.github.com/repos/acme/shop' => Http::response(['id' => 4242]), + '*' => Http::response(['message' => 'Not Found'], 404, ['X-RateLimit-Reset' => now()->timestamp]), + ]); +}); + +it('looks up the repository id with the owner/repo slug when the deployment uses an ssh url', function (string $gitRepository) { + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + $reflection = new ReflectionObject($job); + $reflection->getProperty('application')->setValue($job, $this->application); + $reflection->getProperty('source')->setValue($job, $this->githubApp); + $reflection->getProperty('customRepository')->setValue($job, $gitRepository); + + $reflection->getMethod('update_repository_project_id')->invoke($job); + + Http::assertSent(fn (Request $request) => $request->url() === 'https://api.github.com/repos/acme/shop'); + expect($this->application->refresh()->repository_project_id)->toBe(4242); +})->with([ + 'scp-style ssh url' => ['git@github.com:acme/shop.git'], + 'ssh scheme url' => ['ssh://git@github.com:2222/acme/shop.git'], + 'owner/repo slug' => ['acme/shop'], +]); + +it('stores the repository id when an ssh url application switches to a github app source', function () { + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Livewire::test(Source::class, ['application' => $this->application]) + ->call('changeSource', $this->githubApp->id, GithubApp::class) + ->assertDispatched('success'); + + Http::assertSent(fn (Request $request) => $request->url() === 'https://api.github.com/repos/acme/shop'); + expect($this->application->refresh()->repository_project_id)->toBe(4242); +});