Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/Http/Controllers/GitHubAppWebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Controllers/PluginWebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
51 changes: 51 additions & 0 deletions app/Jobs/BuildPluginInSatis.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace App\Jobs;

use App\Models\Plugin;
use App\Services\SatisService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

/**
* Rebuild a paid plugin in satis.
*
* Unique per plugin until it starts running, because pushes come in bursts and
* one build picks up everything pushed before it runs: satis reads the repo
* when it builds, not when the job was queued.
*/
class BuildPluginInSatis implements ShouldBeUniqueUntilProcessing, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

/**
* Seconds the lock is held at most, so a lost job can't block builds forever.
*/
public int $uniqueFor = 600;

/**
* The plugin may be deleted while the build waits, which removes it from satis anyway.
*/
public bool $deleteWhenMissingModels = true;

public function __construct(public Plugin $plugin) {}

public function uniqueId(): string
{
return (string) $this->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);
}
}
18 changes: 18 additions & 0 deletions app/Models/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
34 changes: 34 additions & 0 deletions tests/Feature/GitHubAppRepositoryEventsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down
107 changes: 107 additions & 0 deletions tests/Feature/PluginWebhookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Tests\Feature;

use App\Jobs\BuildPluginInSatis;
use App\Jobs\SyncPluginReleases;
use App\Models\Plugin;
use App\Services\PluginSyncService;
Expand Down Expand Up @@ -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
{
Expand Down
37 changes: 37 additions & 0 deletions tests/Feature/SatisSync/SatisSyncTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down
Loading