From 83c08dd4e1328935a34390f20419cc5aa319cb63 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 30 Sep 2026 15:07:29 +0000 Subject: [PATCH 1/3] Add Slack incident feed Expose public incident events in a Slack-compatible RSS feed and link entries directly to their status-page updates. Seed an active documentation incident so the demo feed includes an affected component. Amp-Thread-ID: https://ampcode.com/threads/T-01a0f259-2463-7649-ba6a-0251b256031f Co-authored-by: James Brooks --- database/seeders/DatabaseSeeder.php | 16 ++ resources/views/components/incident.blade.php | 4 +- resources/views/slack-rss.blade.php | 45 ++++++ src/Http/Controllers/SlackRssController.php | 130 ++++++++++++++++ src/Models/Incident.php | 4 +- src/Models/Update.php | 4 +- src/PendingRouteRegistration.php | 5 + tests/Feature/SlackRssTest.php | 141 ++++++++++++++++++ tests/Unit/Database/DatabaseSeederTest.php | 20 +++ 9 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 resources/views/slack-rss.blade.php create mode 100644 src/Http/Controllers/SlackRssController.php create mode 100644 tests/Feature/SlackRssTest.php diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 47a62b12..bb947f44 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -232,6 +232,22 @@ public function run(): void $incident->updates()->save($update); + $incident = Incident::create([ + 'name' => 'Documentation Search Unavailable', + 'message' => 'We are investigating an issue preventing searches in the documentation from returning results.', + 'status' => IncidentStatusEnum::investigating, + 'visible' => ResourceVisibilityEnum::guest, + 'guid' => Str::uuid(), + 'user_id' => $user->id, + 'created_at' => $timestamp = now()->subMinutes(2), + 'updated_at' => $timestamp, + 'occurred_at' => $timestamp, + ]); + + $incident->components()->attach($documentation, [ + 'component_status' => ComponentStatusEnum::partial_outage, + ]); + IncidentTemplate::create([ 'name' => 'Third-Party Service Outage', 'slug' => 'third-party-service-outage', diff --git a/resources/views/components/incident.blade.php b/resources/views/components/incident.blade.php index 25136bb1..d9fca34d 100644 --- a/resources/views/components/incident.blade.php +++ b/resources/views/components/incident.blade.php @@ -67,7 +67,7 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
@foreach ($incident->updates as $update) -
+
@if ($headingLevel === 1)

{{ $update->status->getLabel() }}

@@ -80,7 +80,7 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
{!! $update->formattedMessage() !!}
@endforeach -
+
@php($reportStatus = $incident->updates->isEmpty() ? $incident->status : null) @if ($headingLevel === 1) diff --git a/resources/views/slack-rss.blade.php b/resources/views/slack-rss.blade.php new file mode 100644 index 00000000..da0d9972 --- /dev/null +++ b/resources/views/slack-rss.blade.php @@ -0,0 +1,45 @@ +@use('Cachet\Enums\IncidentStatusEnum') +'.PHP_EOL +?> + + + {{ $statusPageName }} status + {{ route('cachet.status-page') }} + {{ $statusPageName }} status page updates + {{ now()->toRssString() }} + https://validator.w3.org/feed/docs/rss2.html + Cachet + @foreach($items as $item) + @php + $incident = $item['incident']; + $update = $item['update']; + $status = $update?->status ?? ($incident->updates->isEmpty() ? $incident->status : IncidentStatusEnum::unknown); + $message = $update?->formattedMessage() ?? $incident->formattedMessage(); + $url = route('cachet.status-page.incident', $incident).'#'.($update ? 'update-'.$update->id : 'reported'); + $description = e($incident->name).'

Status: '.e($status?->getLabel()).'

'.$message; + + if ($incident->components->isNotEmpty()) { + $components = $incident->components->pluck('name')->map(fn (string $name): string => e($name))->join(', '); + $description .= '

⚠️ Affected components: '.$components; + } + + $description = str_replace(']]>', ']]]]>', $description); + @endphp + + @if($update?->status === IncidentStatusEnum::fixed) + <![CDATA[✅ {{ $statusPageName }} - Incident resolved]]> + @elseif($update) + <![CDATA[🚨 {{ $statusPageName }} - Incident update]]> + @else + <![CDATA[🚨 {{ $statusPageName }} - New incident]]> + @endif + {{ $url }} + {{ $url }} + {{ $item['publishedAt']->toRssString() }} + + + + @endforeach +
+
diff --git a/src/Http/Controllers/SlackRssController.php b/src/Http/Controllers/SlackRssController.php new file mode 100644 index 00000000..fabb8ad0 --- /dev/null +++ b/src/Http/Controllers/SlackRssController.php @@ -0,0 +1,130 @@ + $appSettings->name, + 'items' => $this->items($this->feedIncidents($appSettings)), + ])->render(); + }); + + $lastModified = Cache::remember('cachet::slack-rss-feed-last-modified', self::CACHE_TTL, fn () => now()); + + $response = response($feed) + ->header('Content-Type', 'application/rss+xml') + ->setEtag(hash('sha256', $feed)) + ->setLastModified($lastModified) + ->setPublic() + ->setMaxAge(self::CACHE_TTL); + + $response->isNotModified($request); + + return $response; + } + + /** + * Get incidents that can contribute one of the newest feed events. + * + * @return Collection + */ + private function feedIncidents(AppSettings $appSettings): Collection + { + $incidents = $this->incidentQuery($appSettings); + + $incidentIds = (clone $incidents) + ->orderByDesc('created_at') + ->limit(self::MAX_ITEMS) + ->pluck('id'); + + $updatedIncidentIds = Update::query() + ->where('updateable_type', (new Incident)->getMorphClass()) + ->whereIn('updateable_id', (clone $incidents)->select('id')) + ->orderByDesc('created_at') + ->limit(self::MAX_ITEMS) + ->pluck('updateable_id'); + + return (clone $incidents) + ->whereKey($incidentIds->concat($updatedIncidentIds)->unique()) + ->with([ + 'components', + 'updates' => fn ($query) => $query->orderBy('created_at')->orderBy('id'), + ]) + ->get(); + } + + /** + * Build the public incident query shared by both candidate sets. + * + * @return Builder + */ + private function incidentQuery(AppSettings $appSettings): Builder + { + return Incident::query() + ->viewableBy(false) + ->when($appSettings->recent_incidents_only, function (Builder $query) use ($appSettings) { + $query->where(function (Builder $query) use ($appSettings) { + $cutoff = Carbon::now()->subDays($appSettings->recent_incidents_days)->format('Y-m-d'); + + $query->whereDate('occurred_at', '>', $cutoff) + ->orWhere(function (Builder $query) use ($cutoff) { + $query->whereNull('occurred_at')->whereDate('created_at', '>', $cutoff); + }); + }); + }); + } + + /** + * Convert incidents and their updates into one chronological event stream. + * + * @param Collection $incidents + * @return Collection + */ + private function items(Collection $incidents): Collection + { + return $incidents + ->flatMap(function (Incident $incident): Collection { + $reported = collect([[ + 'incident' => $incident, + 'update' => null, + 'publishedAt' => $incident->timestamp, + ]]); + + return $reported->concat($incident->updates->map(fn (Update $update): array => [ + 'incident' => $incident, + 'update' => $update, + 'publishedAt' => $update->created_at ?? $incident->timestamp, + ])); + }) + ->sortByDesc('publishedAt') + ->take(self::MAX_ITEMS) + ->values(); + } +} diff --git a/src/Models/Incident.php b/src/Models/Incident.php index af8f821e..1d760c1d 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -274,12 +274,14 @@ protected function latestStatus(): Attribute } /** - * Clear the cached RSS feed and its HTTP validators. + * Clear the cached RSS feeds and their HTTP validators. */ private static function forgetRssFeed(): void { Cache::forget('cachet::rss-feed'); Cache::forget('cachet::rss-feed-last-modified'); + Cache::forget('cachet::slack-rss-feed'); + Cache::forget('cachet::slack-rss-feed-last-modified'); } /** diff --git a/src/Models/Update.php b/src/Models/Update.php index f90d957f..5456c171 100644 --- a/src/Models/Update.php +++ b/src/Models/Update.php @@ -78,13 +78,15 @@ public function formattedMessage(): string } /** - * Clear the RSS feed when an incident update changes its content. + * Clear the RSS feeds when an incident update changes its content. */ private function forgetRssFeed(): void { if ($this->updateable instanceof Incident) { Cache::forget('cachet::rss-feed'); Cache::forget('cachet::rss-feed-last-modified'); + Cache::forget('cachet::slack-rss-feed'); + Cache::forget('cachet::slack-rss-feed-last-modified'); } } diff --git a/src/PendingRouteRegistration.php b/src/PendingRouteRegistration.php index 449647cc..0437c2a7 100644 --- a/src/PendingRouteRegistration.php +++ b/src/PendingRouteRegistration.php @@ -6,6 +6,7 @@ use Cachet\Http\Controllers\HealthController; use Cachet\Http\Controllers\RssController; use Cachet\Http\Controllers\Setup\SetupController; +use Cachet\Http\Controllers\SlackRssController; use Cachet\Http\Controllers\StatusPage\ComponentBadgeController; use Cachet\Http\Controllers\StatusPage\StatusBadgeController; use Cachet\Http\Controllers\StatusPage\StatusPageController; @@ -64,6 +65,10 @@ public function register(): void ->middleware('throttle:60,1') ->name('rss'); + $router->get('/slack.rss', SlackRssController::class) + ->middleware('throttle:60,1') + ->name('slack-rss'); + }); $this->registerEmailVerificationRoutes(); diff --git a/tests/Feature/SlackRssTest.php b/tests/Feature/SlackRssTest.php new file mode 100644 index 00000000..162a17fc --- /dev/null +++ b/tests/Feature/SlackRssTest.php @@ -0,0 +1,141 @@ +create([ + 'name' => 'Public Incident', + 'visible' => ResourceVisibilityEnum::guest, + ]); + + Update::factory()->forIncident($publicIncident)->create(['message' => 'Public update.']); + + $authenticatedIncident = Incident::factory()->create([ + 'name' => 'Authenticated Incident', + 'visible' => ResourceVisibilityEnum::authenticated, + ]); + + Update::factory()->forIncident($authenticatedIncident)->create(['message' => 'Authenticated update.']); + + $hiddenIncident = Incident::factory()->create([ + 'name' => 'Hidden Incident', + 'visible' => ResourceVisibilityEnum::hidden, + ]); + + Update::factory()->forIncident($hiddenIncident)->create(['message' => 'Hidden update.']); + + $embargoedIncident = Incident::factory()->create([ + 'name' => 'Embargoed Incident', + 'visible' => ResourceVisibilityEnum::guest, + 'published_at' => now()->addWeek(), + ]); + + Update::factory()->forIncident($embargoedIncident)->create(['message' => 'Embargoed update.']); + + $this->get('/status/slack.rss') + ->assertOk() + ->assertSee('Public Incident') + ->assertSee('Public update.') + ->assertDontSee('Authenticated Incident') + ->assertDontSee('Authenticated update.') + ->assertDontSee('Hidden Incident') + ->assertDontSee('Hidden update.') + ->assertDontSee('Embargoed Incident') + ->assertDontSee('Embargoed update.'); +}); + +it('formats incident events for slack', function () { + $reportedAt = Carbon::parse('2026-09-30 09:00:00'); + $identifiedAt = $reportedAt->copy()->addMinute(); + $resolvedAt = $identifiedAt->copy()->addMinute(); + + $component = Component::factory()->create(['name' => 'API']); + $incident = Incident::factory()->create([ + 'name' => 'Elevated error rates', + 'message' => 'We are investigating.', + 'status' => IncidentStatusEnum::fixed, + 'visible' => ResourceVisibilityEnum::guest, + 'created_at' => $reportedAt, + 'updated_at' => $resolvedAt, + ]); + $incident->components()->attach($component); + + $identified = Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::identified, + 'message' => 'We identified the cause.', + 'created_at' => $identifiedAt, + 'updated_at' => $identifiedAt, + ]); + $resolved = Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::fixed, + 'message' => 'Service has recovered.', + 'created_at' => $resolvedAt, + 'updated_at' => $resolvedAt, + ]); + + $response = $this->get('/status/slack.rss') + ->assertOk() + ->assertHeader('Content-Type', 'application/rss+xml'); + + $xml = simplexml_load_string($response->getContent()); + + expect($xml)->not->toBeFalse() + ->and((string) $xml->channel->title)->toBe('Cachet status') + ->and((string) $xml->channel->description)->toBe('Cachet status page updates') + ->and($xml->channel->item)->toHaveCount(3) + ->and((string) $xml->channel->item[0]->title)->toBe('✅ Cachet - Incident resolved') + ->and((string) $xml->channel->item[0]->guid)->toEndWith('#update-'.$resolved->id) + ->and((string) $xml->channel->item[0]->description) + ->toContain('Elevated error rates

Status: Fixed') + ->toContain('Service has recovered.') + ->toContain('⚠️ Affected components: API') + ->and((string) $xml->channel->item[1]->title)->toBe('🚨 Cachet - Incident update') + ->and((string) $xml->channel->item[1]->guid)->toEndWith('#update-'.$identified->id) + ->and((string) $xml->channel->item[2]->title)->toBe('🚨 Cachet - New incident') + ->and((string) $xml->channel->item[2]->guid)->toEndWith('#reported') + ->and((string) $xml->channel->item[2]->description) + ->toContain('Status: Reported') + ->toContain('We are investigating.'); + + $content = $xml->channel->item[0]->children('http://purl.org/rss/1.0/modules/content/'); + + expect((string) $content->encoded)->toBe((string) $xml->channel->item[0]->description); + + $this->get(route('cachet.status-page.incident', $incident)) + ->assertOk() + ->assertSee('id="update-'.$resolved->id.'"', escape: false) + ->assertSee('id="reported"', escape: false); +}); + +it('includes fresh updates to old incidents', function () { + $incident = Incident::factory()->create([ + 'name' => 'Long-running Incident', + 'visible' => ResourceVisibilityEnum::guest, + 'created_at' => now()->subYear(), + ]); + + Update::factory()->forIncident($incident)->create([ + 'message' => 'A fresh update.', + 'created_at' => now(), + ]); + + Incident::factory() + ->count(100) + ->state(new Sequence(fn (Sequence $sequence): array => [ + 'name' => 'Newer Incident '.$sequence->index, + 'visible' => ResourceVisibilityEnum::guest, + 'created_at' => now()->subDays($sequence->index + 1), + ])) + ->create(); + + $this->get('/status/slack.rss') + ->assertOk() + ->assertSee('Long-running Incident') + ->assertSee('A fresh update.'); +}); diff --git a/tests/Unit/Database/DatabaseSeederTest.php b/tests/Unit/Database/DatabaseSeederTest.php index acef7908..d417d38a 100644 --- a/tests/Unit/Database/DatabaseSeederTest.php +++ b/tests/Unit/Database/DatabaseSeederTest.php @@ -2,6 +2,9 @@ use Cachet\Database\Seeders\DatabaseSeeder; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; +use Cachet\Models\Incident; use Cachet\Models\Schedule; use Cachet\Models\Update; @@ -38,3 +41,20 @@ ->toContain('For more information, read our latest [blog post]') ->not->toContain('please you can read'); }); + +it('seeds a public documentation incident', function () { + $this->seed(DatabaseSeeder::class); + + $incident = Incident::query() + ->where('name', 'Documentation Search Unavailable') + ->firstOrFail(); + + expect($incident) + ->status->toBe(IncidentStatusEnum::investigating) + ->visible->toBe(ResourceVisibilityEnum::guest) + ->and($incident->components) + ->toHaveCount(1) + ->first()->name->toBe('Cachet Documentation') + ->and($incident->components->first()->pivot->component_status) + ->toBe(ComponentStatusEnum::partial_outage); +}); From be8b6785ac4e6503caeb8f58a8c10ffd2afbbe30 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 1 Oct 2026 11:38:30 +0000 Subject: [PATCH 2/3] Refine Slack feed content Use Cachet's incident naming convention and surface each event's actual message, status, and affected components for clearer Slack posts. Simplify event collection typing for compatibility with the supported Laravel dependency matrix. Amp-Thread-ID: https://ampcode.com/threads/T-01a0f259-2463-7649-ba6a-0251b256031f Co-authored-by: James Brooks --- resources/views/slack-rss.blade.php | 20 ++++++--------- src/Http/Controllers/SlackRssController.php | 28 +++++++++++++-------- tests/Feature/SlackRssTest.php | 16 ++++++------ 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/resources/views/slack-rss.blade.php b/resources/views/slack-rss.blade.php index da0d9972..6cd95b89 100644 --- a/resources/views/slack-rss.blade.php +++ b/resources/views/slack-rss.blade.php @@ -1,12 +1,11 @@ -@use('Cachet\Enums\IncidentStatusEnum') '.PHP_EOL ?> - {{ $statusPageName }} status + {{ $statusPageName }} incident updates {{ route('cachet.status-page') }} - {{ $statusPageName }} status page updates + Latest incident reports and updates from {{ $statusPageName }} {{ now()->toRssString() }} https://validator.w3.org/feed/docs/rss2.html Cachet @@ -14,26 +13,21 @@ @php $incident = $item['incident']; $update = $item['update']; - $status = $update?->status ?? ($incident->updates->isEmpty() ? $incident->status : IncidentStatusEnum::unknown); + $status = $update ? ($update->status?->getLabel() ?? 'Updated') : 'Reported'; $message = $update?->formattedMessage() ?? $incident->formattedMessage(); $url = route('cachet.status-page.incident', $incident).'#'.($update ? 'update-'.$update->id : 'reported'); - $description = e($incident->name).'

Status: '.e($status?->getLabel()).'

'.$message; + $title = str_replace(']]>', ']]]]>', '['.$status.'] '.$incident->name); + $description = $message.'

Status: '.e($status); if ($incident->components->isNotEmpty()) { $components = $incident->components->pluck('name')->map(fn (string $name): string => e($name))->join(', '); - $description .= '

⚠️ Affected components: '.$components; + $description .= '
Affected components: '.$components; } $description = str_replace(']]>', ']]]]>', $description); @endphp - @if($update?->status === IncidentStatusEnum::fixed) - <![CDATA[✅ {{ $statusPageName }} - Incident resolved]]> - @elseif($update) - <![CDATA[🚨 {{ $statusPageName }} - Incident update]]> - @else - <![CDATA[🚨 {{ $statusPageName }} - New incident]]> - @endif + <![CDATA[{!! $title !!}]]> {{ $url }} {{ $url }} {{ $item['publishedAt']->toRssString() }} diff --git a/src/Http/Controllers/SlackRssController.php b/src/Http/Controllers/SlackRssController.php index fabb8ad0..5523f951 100644 --- a/src/Http/Controllers/SlackRssController.php +++ b/src/Http/Controllers/SlackRssController.php @@ -109,20 +109,26 @@ private function incidentQuery(AppSettings $appSettings): Builder */ private function items(Collection $incidents): Collection { - return $incidents - ->flatMap(function (Incident $incident): Collection { - $reported = collect([[ - 'incident' => $incident, - 'update' => null, - 'publishedAt' => $incident->timestamp, - ]]); - - return $reported->concat($incident->updates->map(fn (Update $update): array => [ + /** @var Collection $items */ + $items = collect(); + + foreach ($incidents as $incident) { + $items->push([ + 'incident' => $incident, + 'update' => null, + 'publishedAt' => $incident->timestamp, + ]); + + foreach ($incident->updates as $update) { + $items->push([ 'incident' => $incident, 'update' => $update, 'publishedAt' => $update->created_at ?? $incident->timestamp, - ])); - }) + ]); + } + } + + return $items ->sortByDesc('publishedAt') ->take(self::MAX_ITEMS) ->values(); diff --git a/tests/Feature/SlackRssTest.php b/tests/Feature/SlackRssTest.php index 162a17fc..e286dbf0 100644 --- a/tests/Feature/SlackRssTest.php +++ b/tests/Feature/SlackRssTest.php @@ -86,21 +86,21 @@ $xml = simplexml_load_string($response->getContent()); expect($xml)->not->toBeFalse() - ->and((string) $xml->channel->title)->toBe('Cachet status') - ->and((string) $xml->channel->description)->toBe('Cachet status page updates') + ->and((string) $xml->channel->title)->toBe('Cachet incident updates') + ->and((string) $xml->channel->description)->toBe('Latest incident reports and updates from Cachet') ->and($xml->channel->item)->toHaveCount(3) - ->and((string) $xml->channel->item[0]->title)->toBe('✅ Cachet - Incident resolved') + ->and((string) $xml->channel->item[0]->title)->toBe('[Fixed] Elevated error rates') ->and((string) $xml->channel->item[0]->guid)->toEndWith('#update-'.$resolved->id) ->and((string) $xml->channel->item[0]->description) - ->toContain('Elevated error rates

Status: Fixed') + ->toContain('Status: Fixed') ->toContain('Service has recovered.') - ->toContain('⚠️ Affected components: API') - ->and((string) $xml->channel->item[1]->title)->toBe('🚨 Cachet - Incident update') + ->toContain('Affected components: API') + ->and((string) $xml->channel->item[1]->title)->toBe('[Identified] Elevated error rates') ->and((string) $xml->channel->item[1]->guid)->toEndWith('#update-'.$identified->id) - ->and((string) $xml->channel->item[2]->title)->toBe('🚨 Cachet - New incident') + ->and((string) $xml->channel->item[2]->title)->toBe('[Reported] Elevated error rates') ->and((string) $xml->channel->item[2]->guid)->toEndWith('#reported') ->and((string) $xml->channel->item[2]->description) - ->toContain('Status: Reported') + ->toContain('Status: Reported') ->toContain('We are investigating.'); $content = $xml->channel->item[0]->children('http://purl.org/rss/1.0/modules/content/'); From 19c3322bae439911d16a1493dfcf488ab418f6eb Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 3 Oct 2026 06:42:42 +0000 Subject: [PATCH 3/3] Fix feed event type compatibility Return a typed event list instead of relying on Illuminate collection generics that differ across supported Laravel dependency versions. Amp-Thread-ID: https://ampcode.com/threads/T-01a0f259-2463-7649-ba6a-0251b256031f Co-authored-by: James Brooks --- src/Http/Controllers/SlackRssController.php | 23 ++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Http/Controllers/SlackRssController.php b/src/Http/Controllers/SlackRssController.php index 5523f951..bf64e94a 100644 --- a/src/Http/Controllers/SlackRssController.php +++ b/src/Http/Controllers/SlackRssController.php @@ -105,32 +105,31 @@ private function incidentQuery(AppSettings $appSettings): Builder * Convert incidents and their updates into one chronological event stream. * * @param Collection $incidents - * @return Collection + * @return list */ - private function items(Collection $incidents): Collection + private function items(Collection $incidents): array { - /** @var Collection $items */ - $items = collect(); + /** @var list $items */ + $items = []; foreach ($incidents as $incident) { - $items->push([ + $items[] = [ 'incident' => $incident, 'update' => null, 'publishedAt' => $incident->timestamp, - ]); + ]; foreach ($incident->updates as $update) { - $items->push([ + $items[] = [ 'incident' => $incident, 'update' => $update, 'publishedAt' => $update->created_at ?? $incident->timestamp, - ]); + ]; } } - return $items - ->sortByDesc('publishedAt') - ->take(self::MAX_ITEMS) - ->values(); + usort($items, fn (array $left, array $right): int => $right['publishedAt']->getTimestamp() <=> $left['publishedAt']->getTimestamp()); + + return array_slice($items, 0, self::MAX_ITEMS); } }