diff --git a/app/Http/Controllers/GitHubAppWebhookController.php b/app/Http/Controllers/GitHubAppWebhookController.php index d9e3de28..eeed1f3e 100644 --- a/app/Http/Controllers/GitHubAppWebhookController.php +++ b/app/Http/Controllers/GitHubAppWebhookController.php @@ -205,6 +205,11 @@ protected function handleRepositoryEvent(string $event, array $payload): JsonRes $syncService = app(PluginSyncService::class); foreach ($plugins as $plugin) { + // Satis publishes every branch and tag, so any push can change what's installable + if ($event === 'push') { + $plugin->queueSatisRebuild(); + } + $syncService->sync($plugin); if ($event === 'release') { diff --git a/app/Http/Controllers/PluginWebhookController.php b/app/Http/Controllers/PluginWebhookController.php index 93853998..d629bcb2 100644 --- a/app/Http/Controllers/PluginWebhookController.php +++ b/app/Http/Controllers/PluginWebhookController.php @@ -47,6 +47,10 @@ public function __invoke(Request $request, string $secret, PluginSyncService $sy } if ($event === 'push') { + // Satis publishes every branch and tag, so any push can change what's installable. + // Queued first so a failed metadata sync doesn't skip the build. + $plugin->queueSatisRebuild(); + $synced = $syncService->sync($plugin); if (! $synced) { diff --git a/app/Jobs/BuildPluginInSatis.php b/app/Jobs/BuildPluginInSatis.php new file mode 100644 index 00000000..680a93b5 --- /dev/null +++ b/app/Jobs/BuildPluginInSatis.php @@ -0,0 +1,51 @@ +plugin->id; + } + + public function handle(SatisService $satisService): void + { + // The plugin may have gone free while the build waited + if (! $this->plugin->isPaid()) { + return; + } + + $satisService->buildForPlugin($this->plugin); + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 16d9671d..15225c88 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -8,6 +8,7 @@ use App\Enums\PluginTier; use App\Enums\PluginType; use App\Enums\PriceTier; +use App\Jobs\BuildPluginInSatis; use App\Jobs\RemovePluginFromSatis; use App\Jobs\SendNewPluginNotifications; use App\Jobs\SuggestPluginCategories; @@ -405,6 +406,23 @@ public function syncToSatis(): void SyncPluginReleases::dispatch($this); } + /** + * Queue a satis build of the plugin's repo as it stands. + * + * Unlike syncToSatis() this doesn't re-sync releases from GitHub, it only + * rebuilds. The build waits a minute so a burst of pushes can share one. + * Free plugins are skipped, since building one would put it in satis. + * Drafts are skipped too, because plugins enter satis on submission. + */ + public function queueSatisRebuild(): void + { + if (! $this->isPaid() || $this->isDraft()) { + return; + } + + BuildPluginInSatis::dispatch($this)->delay(now()->addMinute()); + } + /** * Queue the plugin's removal from satis. * diff --git a/tests/Feature/GitHubAppRepositoryEventsTest.php b/tests/Feature/GitHubAppRepositoryEventsTest.php index 8aa8538b..8102a5a9 100644 --- a/tests/Feature/GitHubAppRepositoryEventsTest.php +++ b/tests/Feature/GitHubAppRepositoryEventsTest.php @@ -3,8 +3,10 @@ namespace Tests\Feature; use App\Enums\PluginStatus; +use App\Enums\PluginType; use App\Features\ShowAuthButtons; use App\Features\ShowPlugins; +use App\Jobs\BuildPluginInSatis; use App\Jobs\SyncPluginReleases; use App\Livewire\Customer\Plugins\Show; use App\Models\GitHubInstallation; @@ -90,6 +92,38 @@ public function test_push_event_from_the_app_syncs_the_matching_plugin(): void Bus::assertNotDispatched(SyncPluginReleases::class); } + public function test_push_event_from_the_app_queues_a_satis_build_for_paid_plugins(): void + { + Bus::fake([SyncPluginReleases::class, BuildPluginInSatis::class]); + $plugin = $this->coveredPlugin(['type' => PluginType::Paid]); + + $this->mock(PluginSyncService::class, function (MockInterface $mock): void { + $mock->shouldReceive('sync')->once()->andReturn(true); + }); + + $this->sendAppWebhook('push', ['ref' => 'refs/heads/main', 'repository' => ['full_name' => 'acme/camera-plugin']]) + ->assertOk(); + + Bus::assertDispatched(BuildPluginInSatis::class, fn (BuildPluginInSatis $job) => $job->plugin->is($plugin)); + } + + public function test_release_event_from_the_app_does_not_queue_a_satis_build(): void + { + Bus::fake([SyncPluginReleases::class, BuildPluginInSatis::class]); + $plugin = $this->coveredPlugin(['type' => PluginType::Paid]); + + $this->mock(PluginSyncService::class, function (MockInterface $mock): void { + $mock->shouldReceive('sync')->once()->andReturn(true); + }); + + $this->sendAppWebhook('release', $this->releasePayload('published', 101)) + ->assertOk(); + + // SyncPluginReleases runs its own satis build + Bus::assertDispatched(SyncPluginReleases::class, fn (SyncPluginReleases $job) => $job->plugin->is($plugin)); + Bus::assertNotDispatched(BuildPluginInSatis::class); + } + public function test_release_event_from_the_app_syncs_releases(): void { Bus::fake([SyncPluginReleases::class]); diff --git a/tests/Feature/PluginWebhookTest.php b/tests/Feature/PluginWebhookTest.php index da8b84bd..b979fa24 100644 --- a/tests/Feature/PluginWebhookTest.php +++ b/tests/Feature/PluginWebhookTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Jobs\BuildPluginInSatis; use App\Jobs\SyncPluginReleases; use App\Models\Plugin; use App\Services\PluginSyncService; @@ -81,6 +82,112 @@ public function non_ping_event_succeeds_for_unapproved_but_active_plugin(): void ->assertJson(['success' => true]); } + #[Test] + public function push_queues_a_delayed_satis_build_for_a_paid_plugin(): void + { + Bus::fake([BuildPluginInSatis::class]); + $this->freezeTime(); + + $plugin = Plugin::factory()->paid()->create(['last_synced_at' => now()]); + + $this->mock(PluginSyncService::class, function ($mock) { + $mock->shouldReceive('sync')->once()->andReturn(true); + }); + + $this->postJson( + route('webhooks.plugins', $plugin->webhook_secret), + ['ref' => 'refs/heads/main'], + ['X-GitHub-Event' => 'push'] + )->assertOk(); + + Bus::assertDispatched(BuildPluginInSatis::class, fn (BuildPluginInSatis $job) => $job->plugin->is($plugin) + && $job->delay->equalTo(now()->addMinute())); + } + + #[Test] + public function push_does_not_queue_a_satis_build_for_a_free_plugin(): void + { + Bus::fake([BuildPluginInSatis::class]); + + $plugin = Plugin::factory()->free()->create(['last_synced_at' => now()]); + + $this->mock(PluginSyncService::class, function ($mock) { + $mock->shouldReceive('sync')->once()->andReturn(true); + }); + + $this->postJson( + route('webhooks.plugins', $plugin->webhook_secret), + ['ref' => 'refs/heads/main'], + ['X-GitHub-Event' => 'push'] + )->assertOk(); + + Bus::assertNotDispatched(BuildPluginInSatis::class); + } + + #[Test] + public function push_does_not_queue_a_satis_build_for_a_paid_draft(): void + { + Bus::fake([BuildPluginInSatis::class]); + + $plugin = Plugin::factory()->paid()->draft()->create(['last_synced_at' => now()]); + $plugin->generateWebhookSecret(); + + $this->mock(PluginSyncService::class, function ($mock) { + $mock->shouldReceive('sync')->once()->andReturn(true); + }); + + $this->postJson( + route('webhooks.plugins', $plugin->webhook_secret), + ['ref' => 'refs/heads/main'], + ['X-GitHub-Event' => 'push'] + )->assertOk(); + + Bus::assertNotDispatched(BuildPluginInSatis::class); + } + + #[Test] + public function push_still_queues_a_satis_build_when_the_metadata_sync_fails(): void + { + Bus::fake([BuildPluginInSatis::class]); + + $plugin = Plugin::factory()->paid()->create(['last_synced_at' => now()]); + + $this->mock(PluginSyncService::class, function ($mock) { + $mock->shouldReceive('sync')->once()->andReturn(false); + }); + + $this->postJson( + route('webhooks.plugins', $plugin->webhook_secret), + ['ref' => 'refs/heads/main'], + ['X-GitHub-Event' => 'push'] + )->assertServerError(); + + Bus::assertDispatched(BuildPluginInSatis::class, fn (BuildPluginInSatis $job) => $job->plugin->is($plugin)); + } + + #[Test] + public function a_burst_of_pushes_queues_one_satis_build(): void + { + Bus::fake([BuildPluginInSatis::class]); + + $plugin = Plugin::factory()->paid()->create(['last_synced_at' => now()]); + + $this->mock(PluginSyncService::class, function ($mock) { + $mock->shouldReceive('sync')->twice()->andReturn(true); + }); + + // The unique lock is taken at dispatch and held until the build starts running + foreach (['refs/heads/main', 'refs/heads/wip'] as $ref) { + $this->postJson( + route('webhooks.plugins', $plugin->webhook_secret), + ['ref' => $ref], + ['X-GitHub-Event' => 'push'] + )->assertOk(); + } + + Bus::assertDispatchedTimes(BuildPluginInSatis::class, 1); + } + #[Test] public function published_release_queues_a_release_sync(): void { diff --git a/tests/Feature/SatisSync/SatisSyncTest.php b/tests/Feature/SatisSync/SatisSyncTest.php index 52d81be7..5ac6fe69 100644 --- a/tests/Feature/SatisSync/SatisSyncTest.php +++ b/tests/Feature/SatisSync/SatisSyncTest.php @@ -4,6 +4,7 @@ use App\Enums\PluginType; use App\Filament\Resources\PluginResource\Pages\EditPlugin; +use App\Jobs\BuildPluginInSatis; use App\Jobs\RemovePluginFromSatis; use App\Jobs\SyncPluginReleases; use App\Models\Plugin; @@ -169,6 +170,42 @@ public function test_remove_plugin_from_satis_job_calls_the_satis_api(): void (new RemovePluginFromSatis('acme/widget'))->handle($satisService); } + public function test_build_plugin_in_satis_job_builds_the_plugin_and_stamps_satis_synced_at(): void + { + Http::fake(['*' => Http::response(['job_id' => 'test-123', 'message' => 'Build started'], 200)]); + + config(['services.satis.url' => 'https://satis.test', 'services.satis.api_key' => 'test-key']); + + $plugin = Plugin::factory()->paid()->approved()->create(); + + (new BuildPluginInSatis($plugin))->handle(new SatisService); + + Http::assertSent(function ($request) use ($plugin) { + return $request->url() === 'https://satis.test/api/build' + && $request->data()['plugins'][0]['name'] === $plugin->name; + }); + + $this->assertNotNull($plugin->fresh()->satis_synced_at); + } + + public function test_build_plugin_in_satis_job_skips_a_plugin_that_has_gone_free(): void + { + Http::fake(); + Bus::fake([RemovePluginFromSatis::class]); + + config(['services.satis.url' => 'https://satis.test', 'services.satis.api_key' => 'test-key']); + + $plugin = Plugin::factory()->paid()->approved()->create(); + $job = new BuildPluginInSatis($plugin); + + $plugin->update(['type' => PluginType::Free]); + + $job->handle(new SatisService); + + Http::assertNothingSent(); + $this->assertNull($plugin->fresh()->satis_synced_at); + } + public function test_filament_sync_to_satis_action_dispatches_job(): void { Bus::fake([SyncPluginReleases::class]);