From d8972e97c976c0111c3399bf41e38da9c19ca562 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 13 May 2026 09:31:28 +0200 Subject: [PATCH 01/13] fix(previews): clean up closed PR previews after update failures Catch and report failures while updating closed pull request status so preview deployment cleanup still runs for closed GitHub pull request webhooks. Add coverage for cleanup continuing when GitHub comment cleanup fails. --- app/Jobs/ProcessGithubPullRequestWebhook.php | 24 +++++-- .../ProcessGithubPullRequestWebhookTest.php | 72 +++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/ProcessGithubPullRequestWebhookTest.php diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 54e386676..5a390e8ed 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Throwable; use Visus\Cuid2\Cuid2; class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue @@ -70,16 +71,25 @@ private function handleClosedAction(Application $application): void ->first(); if ($found) { - ApplicationPullRequestUpdateJob::dispatchSync( - application: $application, - preview: $found, - status: ProcessStatus::CLOSED - ); - - CleanupPreviewDeployment::run($application, $this->pullRequestId, $found); + try { + $this->dispatchPullRequestClosedUpdate($application, $found); + } catch (Throwable $e) { + report($e); + } finally { + CleanupPreviewDeployment::run($application, $this->pullRequestId, $found); + } } } + protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void + { + ApplicationPullRequestUpdateJob::dispatchSync( + application: $application, + preview: $preview, + status: ProcessStatus::CLOSED + ); + } + private function handleOpenAction(Application $application, ?GithubApp $githubApp): void { if (! $application->isPRDeployable()) { diff --git a/tests/Feature/ProcessGithubPullRequestWebhookTest.php b/tests/Feature/ProcessGithubPullRequestWebhookTest.php new file mode 100644 index 000000000..6e0e9241e --- /dev/null +++ b/tests/Feature/ProcessGithubPullRequestWebhookTest.php @@ -0,0 +1,72 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +it('cleans up a closed pull request preview when pull request comment cleanup fails', function () { + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/42', + ]); + + CleanupPreviewDeployment::shouldRun() + ->once() + ->withArgs(fn (Application $application, int $pullRequestId, ApplicationPreview $applicationPreview): bool => $application->is($this->application) + && $pullRequestId === 42 + && $applicationPreview->is($preview)) + ->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); + + $job = new class( + applicationId: $this->application->id, + githubAppId: null, + action: 'closed', + pullRequestId: 42, + pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42', + pullRequestTitle: null, + beforeSha: null, + afterSha: null, + commitSha: 'HEAD', + authorAssociation: 'OWNER', + fullName: 'example/repo', + ) extends ProcessGithubPullRequestWebhook + { + protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void + { + throw new RuntimeException('GitHub comment cleanup failed.'); + } + }; + + $job->handle(); +}); From 22f9f96db68efb6009a3cd2085b317ab8d882992 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:24:53 +0200 Subject: [PATCH 02/13] chore: inspect staged changes for commit message --- app/Console/Commands/Generate/Services.php | 24 ++ app/Livewire/GlobalSearch.php | 8 + app/Livewire/Project/New/Select.php | 37 +- bootstrap/helpers/shared.php | 16 +- public/svgs/clickhouse-icon.svg | 8 + public/svgs/clickhouse.svg | 9 +- public/svgs/dragonfly.svg | 1 + public/svgs/keydb.svg | 1 + .../views/livewire/global-search.blade.php | 12 +- .../views/livewire/project/index.blade.php | 71 ++-- templates/service-templates-latest.json | 401 ++++++++++++++++-- templates/service-templates.json | 401 ++++++++++++++++-- tests/Feature/ProjectIndexEmptyStateTest.php | 38 ++ .../ServiceTemplateGitTimestampTest.php | 29 ++ .../ServiceTemplatesLastUpdatedHintTest.php | 51 ++- .../GlobalSearchNewImageQuickActionTest.php | 106 ++++- 16 files changed, 1071 insertions(+), 142 deletions(-) create mode 100644 public/svgs/clickhouse-icon.svg create mode 100644 public/svgs/dragonfly.svg create mode 100644 public/svgs/keydb.svg create mode 100644 tests/Feature/ProjectIndexEmptyStateTest.php create mode 100644 tests/Feature/ServiceTemplateGitTimestampTest.php diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php index e316fc391..2978feb3c 100644 --- a/app/Console/Commands/Generate/Services.php +++ b/app/Console/Commands/Generate/Services.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Process; use Symfony\Component\Yaml\Yaml; class Services extends Command @@ -77,6 +78,7 @@ private function processFile(string $file): false|array 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -99,6 +101,26 @@ private function processFile(string $file): false|array return $payload; } + private function templateLastUpdatedAt(string $file): ?string + { + $process = Process::path(base_path())->run([ + 'git', + 'log', + '-1', + '--format=%cI', + '--', + "templates/compose/{$file}", + ]); + + if ($process->failed()) { + return null; + } + + $timestamp = trim($process->output()); + + return $timestamp === '' ? null : $timestamp; + } + private function generateServiceTemplatesWithFqdn(): void { $serviceTemplatesWithFqdn = collect(array_merge( @@ -155,6 +177,7 @@ private function processFileWithFqdn(string $file): false|array 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -232,6 +255,7 @@ private function processFileWithFqdnRaw(string $file): false|array 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index df2adf22b..4148764de 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1053,6 +1053,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new postgresql)', 'type' => 'postgresql', 'category' => 'Databases', + 'logo' => 'svgs/postgresql.svg', 'resourceType' => 'database', ]); @@ -1062,6 +1063,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new mysql)', 'type' => 'mysql', 'category' => 'Databases', + 'logo' => 'svgs/mysql.svg', 'resourceType' => 'database', ]); @@ -1071,6 +1073,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new mariadb)', 'type' => 'mariadb', 'category' => 'Databases', + 'logo' => 'svgs/mariadb.svg', 'resourceType' => 'database', ]); @@ -1080,6 +1083,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new redis)', 'type' => 'redis', 'category' => 'Databases', + 'logo' => 'svgs/redis.svg', 'resourceType' => 'database', ]); @@ -1089,6 +1093,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new keydb)', 'type' => 'keydb', 'category' => 'Databases', + 'logo' => 'svgs/keydb.svg', 'resourceType' => 'database', ]); @@ -1098,6 +1103,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new dragonfly)', 'type' => 'dragonfly', 'category' => 'Databases', + 'logo' => 'svgs/dragonfly.svg', 'resourceType' => 'database', ]); @@ -1107,6 +1113,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new mongodb)', 'type' => 'mongodb', 'category' => 'Databases', + 'logo' => 'svgs/mongodb.svg', 'resourceType' => 'database', ]); @@ -1116,6 +1123,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new clickhouse)', 'type' => 'clickhouse', 'category' => 'Databases', + 'logo' => 'svgs/clickhouse-icon.svg', 'resourceType' => 'database', ]); } diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index d6d234b18..cff886f98 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -6,7 +6,6 @@ use App\Models\Server; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Livewire\Component; class Select extends Component @@ -107,7 +106,7 @@ public function updatedSelectedEnvironment() public function loadServices() { $services = get_service_templates(); - $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys()); + $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { $default_logo = 'images/default.webp'; @@ -279,19 +278,31 @@ private function serviceTemplatesLastUpdated(): ?string return $this->formatLastModified($this->serviceTemplatesPath()); } - private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array + private function serviceTemplateLastUpdatedMap(Collection $services): array { - $bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0; + return $services + ->mapWithKeys(fn ($service, $serviceName) => [ + (string) $serviceName => $this->serviceTemplateLastUpdatedFromPayload($service) + ?? $this->serviceTemplateLastUpdated((string) $serviceName), + ]) + ->all(); + } - return Cache::remember( - "service-template-last-updated-map:{$bundleMtime}", - now()->addDay(), - fn () => $serviceNames - ->mapWithKeys(fn ($serviceName) => [ - (string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName), - ]) - ->all() - ); + private function serviceTemplateLastUpdatedFromPayload(mixed $service): ?string + { + $timestamp = data_get($service, 'template_last_updated_at'); + + if (! is_string($timestamp) || $timestamp === '') { + return null; + } + + try { + return CarbonImmutable::parse($timestamp) + ->timezone(config('app.timezone')) + ->format('M j, Y H:i'); + } catch (\Throwable) { + return null; + } } private function serviceTemplateLastUpdated(string $serviceName): ?string diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f2b672fef..8fdfceaf1 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1057,7 +1057,6 @@ function sslip(Server $server) function get_service_templates(bool $force = false): Collection { - if ($force) { try { $response = Http::retry(3, 1000)->get(config('constants.services.official')); @@ -1068,15 +1067,16 @@ function get_service_templates(bool $force = false): Collection return collect($services); } catch (Throwable) { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); + return get_service_templates(); } - } else { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); } + + $path = base_path('templates/'.config('constants.services.file_name')); + $mtime = filemtime($path) ?: 0; + + return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) { + return collect(json_decode(File::get($path)))->sortKeys(); + }); } function getResourceByUuid(string $uuid, ?int $teamId = null) diff --git a/public/svgs/clickhouse-icon.svg b/public/svgs/clickhouse-icon.svg new file mode 100644 index 000000000..e327a2a73 --- /dev/null +++ b/public/svgs/clickhouse-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/svgs/clickhouse.svg b/public/svgs/clickhouse.svg index d536536de..e327a2a73 100644 --- a/public/svgs/clickhouse.svg +++ b/public/svgs/clickhouse.svg @@ -1 +1,8 @@ - + + + + + + + + diff --git a/public/svgs/dragonfly.svg b/public/svgs/dragonfly.svg new file mode 100644 index 000000000..d762f3e03 --- /dev/null +++ b/public/svgs/dragonfly.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/keydb.svg b/public/svgs/keydb.svg new file mode 100644 index 000000000..0abc24a81 --- /dev/null +++ b/public/svgs/keydb.svg @@ -0,0 +1 @@ + diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 3316c110f..3169c7fc9 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -632,7 +632,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr @foreach ($searchResults as $result) @if (!isset($result['is_creatable_suggestion'])) + class="search-result-item block px-4 py-3 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-neutral-100 dark:focus:bg-coolgray-200 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-coollabs dark:focus-visible:ring-warning">