fix: honor GitHub default branches and reconcile proxy networks

Use searchable repository and branch selectors, select each repository's default branch when available, and discover running container networks during proxy reconciliation.
This commit is contained in:
Andras Bacsai 2026-08-18 09:16:29 +02:00
parent 17d9d1a370
commit 0d23b29775
7 changed files with 123 additions and 22 deletions

View file

@ -134,8 +134,9 @@ public function loadRepositories(int $github_app_id): void
public function loadBranches()
{
$this->selected_repository_owner = $this->repositories->where('id', $this->selected_repository_id)->first()['owner']['login'];
$this->selected_repository_repo = $this->repositories->where('id', $this->selected_repository_id)->first()['name'];
$repository = $this->repositories->firstWhere('id', $this->selected_repository_id);
$this->selected_repository_owner = data_get($repository, 'owner.login');
$this->selected_repository_repo = data_get($repository, 'name');
$this->branches = collect();
$this->page = 1;
$this->loadBranchByPage();
@ -146,7 +147,10 @@ public function loadBranches()
}
}
$this->branches = sortBranchesByPriority($this->branches);
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
$defaultBranch = data_get($repository, 'default_branch', 'main');
$this->selected_branch_name = $this->branches->contains('name', $defaultBranch)
? $defaultBranch
: data_get($this->branches, '0.name', 'main');
}
protected function loadBranchByPage()

View file

@ -107,8 +107,8 @@ function collectDockerNetworksByServer(Server $server)
}
function connectProxyToNetworks(Server $server)
{
['networks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) {
['networks' => $networks] = collectDockerNetworksByServer($server);
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
@ -118,21 +118,22 @@ function connectProxyToNetworks(Server $server)
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
}
return $commands->flatten();
}
return collect([
'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do',
' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then',
' continue',
' fi',
' if docker network inspect "$network" >/dev/null 2>&1; then',
' docker network connect "$network" coolify-proxy >/dev/null 2>&1 || true',
' fi',
'done',
]);
}
/**
* Ensures all required networks exist before docker compose up.
* This must be called BEFORE docker compose up since the compose file declares networks as external.

View file

@ -111,7 +111,7 @@
@click.stop>
<div class="searchable-listbox-search">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
class="pointer-events-none absolute top-1/2 left-3 size-3 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-ref="search" type="search" x-model="query" autocomplete="off"
placeholder="{{ $searchPlaceholder }}"
class="searchable-listbox-search-input"

View file

@ -72,10 +72,11 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10
<div class="application-settings-section-body">
@if ($repositories->isNotEmpty())
<div class="flex items-end gap-2">
<x-forms.listbox id="selected_repository_id" label="Repository" required live
<x-forms.searchable-listbox id="selected_repository_id" label="Repository" required live
searchPlaceholder="Search repositories…"
:options="$repositories->map(fn ($repository) => [
'value' => data_get($repository, 'id'),
'label' => data_get($repository, 'name'),
'label' => data_get($repository, 'full_name', data_get($repository, 'name')),
])->values()->all()" />
<x-forms.button :showLoadingIndicator="false" wire:click.prevent="loadBranches"
wire:loading.attr="disabled"
@ -104,7 +105,8 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10
</div>
<div class="application-settings-section-body space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="selected_branch_name" label="Branch" required
<x-forms.searchable-listbox id="selected_branch_name" label="Branch" required
searchPlaceholder="Search branches…"
:options="$branches->map(fn ($branch) => [
'value' => data_get($branch, 'name'),
'label' => data_get($branch, 'name'),

View file

@ -105,19 +105,59 @@ function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
->assertSet('selected_repository_id', 1);
});
test('repository selection uses the shared listbox and disables loading action', function () {
test('repository selection uses the searchable listbox and disables loading action', function () {
fakeGithubHttp([
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
]);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSee('id="selected_repository_id-trigger"', false)
->assertSee('class="listbox-panel searchable-listbox-panel"', false)
->assertSee('placeholder="Search repositories…"', false)
->assertSee('wire:loading.attr="disabled"', false)
->assertSee('wire:target="loadBranches,selected_repository_id"', false)
->assertDontSee('<datalist', false);
});
test('loadBranches fetches available branches and selects the repository default branch', function () {
$repository = [
'id' => 1,
'name' => 'alpha-repo',
'default_branch' => 'production',
'owner' => ['login' => 'testuser'],
];
fakeGithubHttp([$repository]);
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'fake-installation-token',
], 201),
'https://api.github.com/installation/repositories*' => Http::response([
'total_count' => 1,
'repositories' => [$repository],
], 200),
'https://api.github.com/repos/testuser/alpha-repo/branches*' => Http::response([
['name' => 'main'],
['name' => 'feature/login'],
['name' => 'production'],
], 200),
]);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->call('loadBranches')
->assertSet('selected_branch_name', 'production')
->assertSet('branches', collect([
['name' => 'main'],
['name' => 'feature/login'],
['name' => 'production'],
]))
->assertSee('placeholder="Search branches…"', false);
});
test('continue button uses the shared submit loading indicator', function () {
fakeGithubHttp([
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
@ -301,11 +341,11 @@ function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSee('Refresh Repository List');
->assertSee('Refresh');
});
test('refresh button is not visible before repositories are loaded', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertDontSee('Refresh Repository List');
->assertDontSee('Refresh');
});
});

View file

@ -0,0 +1,53 @@
<?php
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('discovers running container networks instead of relying on cached resource statuses', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$commands = connectProxyToNetworks($server)->implode("\n");
expect($commands)
->toContain('docker ps --filter label=coolify.managed=true')
->toContain('.NetworkSettings.Networks')
->toContain('docker network inspect "$network"')
->toContain('docker network connect "$network" coolify-proxy')
->not->toContain('docker network create');
});
it('skips Docker system networks during runtime reconciliation', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$commands = connectProxyToNetworks($server)->implode("\n");
expect($commands)
->toContain('"$network" = "bridge"')
->toContain('"$network" = "host"')
->toContain('"$network" = "none"')
->toContain('"$network" = "default"');
});
it('preserves runtime reconciliation loops for non-root SSH users', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'user' => 'ubuntu',
]);
$commands = collect(parseCommandsByLineForSudo(connectProxyToNetworks($server), $server))->implode("\n");
expect($commands)
->toContain('$(sudo docker ps')
->toMatch('/sudo\s+docker inspect/')
->toMatch('/sudo\s+docker network inspect/')
->toMatch('/sudo\s+docker network connect/')
->not->toContain('sudo while')
->not->toContain('sudo done')
->not->toContain('sudo continue');
});

View file

@ -19,6 +19,7 @@
->toContain('No matching timezone')
->toContain('serverTimezone-trigger')
->toContain('x-ref="search"')
->toContain('left-3 size-3')
->toContain('get filtered()')
->toContain('searchable-listbox-panel')
->toContain('Berlin')