fix(github): preserve custom app API URLs

This commit is contained in:
Andras Bacsai 2026-07-03 12:01:16 +02:00
parent a7dddccd2e
commit 1adeed2a48
6 changed files with 101 additions and 25 deletions

View file

@ -256,7 +256,9 @@ public function create_github_app(Request $request)
'uuid' => Str::uuid(), 'uuid' => Str::uuid(),
'name' => $request->input('name'), 'name' => $request->input('name'),
'organization' => $request->input('organization'), 'organization' => $request->input('organization'),
'api_url' => githubApiUrlFromHtmlUrl($request->input('html_url')), 'api_url' => filled($request->input('api_url'))
? $request->input('api_url')
: githubApiUrlFromHtmlUrl($request->input('html_url')),
'html_url' => $request->input('html_url'), 'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'), 'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22), 'custom_port' => $request->input('custom_port', 22),
@ -650,7 +652,7 @@ public function update_github_app(Request $request, $github_app_id)
if (array_key_exists('organization', $payload)) { if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']); $payload['organization'] = normalizeGithubOrganization($payload['organization']);
} }
if (isset($payload['html_url'])) { if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']); $payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
} }

View file

@ -79,6 +79,8 @@ class Change extends Component
public string $activeTab = 'general'; public string $activeTab = 'general';
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -104,9 +106,17 @@ protected function rules(): array
]; ];
} }
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl)
|| $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl);
}
public function updatedHtmlUrl(): void public function updatedHtmlUrl(): void
{ {
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
}
} }
public function boot() public function boot()
@ -126,7 +136,9 @@ private function syncData(bool $toModel = false): void
if ($toModel) { if ($toModel) {
// Sync TO model (before save) // Sync TO model (before save)
$this->organization = normalizeGithubOrganization($this->organization); $this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); $this->apiUrl = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->github_app->name = $this->name; $this->github_app->name = $this->name;
$this->github_app->organization = $this->organization; $this->github_app->organization = $this->organization;
@ -354,7 +366,9 @@ public function submit()
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->organization = normalizeGithubOrganization($this->organization); $this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); $this->apiUrl = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->validate(); $this->validate();
$this->syncData(true); $this->syncData(true);

View file

@ -5,6 +5,7 @@
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Rules\SafeExternalUrl; use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component; use Livewire\Component;
class Create extends Component class Create extends Component
@ -25,14 +26,24 @@ class Create extends Component
public bool $is_system_wide = false; public bool $is_system_wide = false;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
public function mount() public function mount()
{ {
$this->name = substr(generate_random_name(), 0, 30); $this->name = substr(generate_random_name(), 0, 30);
} }
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url)
|| $this->api_url === githubApiUrlFromHtmlUrl($this->html_url);
}
public function updatedHtmlUrl(): void public function updatedHtmlUrl(): void
{ {
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url); if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
}
} }
public function createGitHubApp() public function createGitHubApp()
@ -41,7 +52,9 @@ public function createGitHubApp()
$this->authorize('createAnyResource'); $this->authorize('createAnyResource');
$this->organization = normalizeGithubOrganization($this->organization); $this->organization = normalizeGithubOrganization($this->organization);
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url); $this->api_url = filled($this->api_url)
? $this->api_url
: githubApiUrlFromHtmlUrl($this->html_url);
$this->validate([ $this->validate([
'name' => 'required|string', 'name' => 'required|string',
@ -68,6 +81,8 @@ public function createGitHubApp()
} }
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]); return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
@ -10,19 +11,22 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
// Create a team with owner // Create a team with owner
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
// Create an API token for the user // Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id); $this->token = $this->user->createToken('test-token');
$this->bearerToken = $this->token->plainTextToken; $this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team // Create a private key for the team
$this->privateKey = PrivateKey::create([ $this->privateKey = PrivateKey::create([
'name' => 'Test Key', 'name' => 'Test Key',
'private_key' => 'test-private-key-content', 'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $this->team->id, 'team_id' => $this->team->id,
]); ]);
}); });
@ -139,7 +143,8 @@ function validGithubAppsApiPrivateKey(): string
$otherTeam = Team::factory()->create(); $otherTeam = Team::factory()->create();
$otherUser = User::factory()->create(); $otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id); session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-token');
// System-wide apps should be visible to other teams // System-wide apps should be visible to other teams
$response = $this->withHeaders([ $response = $this->withHeaders([
@ -173,7 +178,7 @@ function validGithubAppsApiPrivateKey(): string
$otherTeam = Team::factory()->create(); $otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([ $otherPrivateKey = PrivateKey::create([
'name' => 'Other Key', 'name' => 'Other Key',
'private_key' => 'other-key', 'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $otherTeam->id, 'team_id' => $otherTeam->id,
]); ]);
GithubApp::create([ GithubApp::create([
@ -249,7 +254,7 @@ function validGithubAppsApiPrivateKey(): string
])->postJson('/api/v1/github-apps', [ ])->postJson('/api/v1/github-apps', [
'name' => 'GHE App', 'name' => 'GHE App',
'organization' => '/octocorp/', 'organization' => '/octocorp/',
'html_url' => 'https://octocorp.ghe.com', 'html_url' => 'https://github.ghe.com',
'app_id' => 12345, 'app_id' => 12345,
'installation_id' => 67890, 'installation_id' => 67890,
'client_id' => 'test-client-id', 'client_id' => 'test-client-id',
@ -261,12 +266,36 @@ function validGithubAppsApiPrivateKey(): string
$response->assertCreated() $response->assertCreated()
->assertJsonFragment([ ->assertJsonFragment([
'organization' => 'octocorp', 'organization' => 'octocorp',
'api_url' => 'https://api.octocorp.ghe.com', 'api_url' => 'https://api.github.ghe.com',
'html_url' => 'https://octocorp.ghe.com', 'html_url' => 'https://github.ghe.com',
]); ]);
}); });
test('normalizes ghe dot com api url when updating github apps', function () { test('preserves provided api url when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated()
->assertJsonFragment([
'organization' => 'octocorp',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.com',
]);
});
test('preserves provided api url when updating github apps', function () {
$githubApp = GithubApp::create([ $githubApp = GithubApp::create([
'name' => 'GHE App', 'name' => 'GHE App',
'api_url' => 'https://github.company.internal/api/v3', 'api_url' => 'https://github.company.internal/api/v3',
@ -285,12 +314,12 @@ function validGithubAppsApiPrivateKey(): string
$response = $this->withHeaders([ $response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken, 'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [ ])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
'html_url' => 'https://octocorp.ghe.com', 'html_url' => 'https://github.com',
'api_url' => 'https://octocorp.ghe.com/api/v3', 'api_url' => 'https://github.com/api/v3',
]); ]);
$response->assertSuccessful() $response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://api.octocorp.ghe.com'); ->assertJsonPath('data.api_url', 'https://github.com/api/v3');
}); });
test('rejects invalid organization when creating github apps', function () { test('rejects invalid organization when creating github apps', function () {

View file

@ -419,7 +419,7 @@ function validPrivateKey(): string
expect($githubApp->private_key_id)->toBe($privateKey->id); expect($githubApp->private_key_id)->toBe($privateKey->id);
}); });
test('normalizes resolvable ghe dot com api url when saving github app settings', function () { test('preserves custom ghe api url when saving github app settings', function () {
$githubApp = GithubApp::create([ $githubApp = GithubApp::create([
'name' => 'Test GitHub App', 'name' => 'Test GitHub App',
'api_url' => 'https://github.ghe.com/api/v3', 'api_url' => 'https://github.ghe.com/api/v3',
@ -437,10 +437,10 @@ function validPrivateKey(): string
->set('apiUrl', 'https://github.ghe.com/api/v3') ->set('apiUrl', 'https://github.ghe.com/api/v3')
->call('submit') ->call('submit')
->assertDispatched('success') ->assertDispatched('success')
->assertSet('apiUrl', 'https://api.github.ghe.com'); ->assertSet('apiUrl', 'https://github.ghe.com/api/v3');
$githubApp->refresh(); $githubApp->refresh();
expect($githubApp->api_url)->toBe('https://api.github.ghe.com'); expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
}); });
test('rejects invalid github organization values', function () { test('rejects invalid github organization values', function () {

View file

@ -72,8 +72,8 @@
Livewire::test(Create::class) Livewire::test(Create::class)
->assertSuccessful() ->assertSuccessful()
->set('name', 'enterprise-app') ->set('name', 'enterprise-app')
->set('api_url', 'https://github.enterprise.com/api/v3') ->set('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.enterprise.com') ->set('html_url', 'https://github.ghe.com')
->set('custom_user', 'git-custom') ->set('custom_user', 'git-custom')
->set('custom_port', 2222) ->set('custom_port', 2222)
->call('createGitHubApp') ->call('createGitHubApp')
@ -82,12 +82,28 @@
$githubApp = GithubApp::where('name', 'enterprise-app')->first(); $githubApp = GithubApp::where('name', 'enterprise-app')->first();
expect($githubApp)->not->toBeNull(); expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3'); expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.enterprise.com'); expect($githubApp->html_url)->toBe('https://github.ghe.com');
expect($githubApp->custom_user)->toBe('git-custom'); expect($githubApp->custom_user)->toBe('git-custom');
expect($githubApp->custom_port)->toBe(2222); expect($githubApp->custom_port)->toBe(2222);
}); });
test('preserves custom github enterprise api url when creating github app', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'ghe-custom-api-app')
->set('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.ghe.com')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'ghe-custom-api-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.ghe.com');
});
test('validates required fields', function () { test('validates required fields', function () {
Livewire::test(Create::class) Livewire::test(Create::class)
->assertSuccessful() ->assertSuccessful()