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..6cd95b89
--- /dev/null
+++ b/resources/views/slack-rss.blade.php
@@ -0,0 +1,39 @@
+=
+''.PHP_EOL
+?>
+
+
+ {{ $statusPageName }} incident updates
+ {{ route('cachet.status-page') }}
+ Latest incident reports and updates from {{ $statusPageName }}
+ {{ now()->toRssString() }}
+ https://validator.w3.org/feed/docs/rss2.html
+ Cachet
+ @foreach($items as $item)
+ @php
+ $incident = $item['incident'];
+ $update = $item['update'];
+ $status = $update ? ($update->status?->getLabel() ?? 'Updated') : 'Reported';
+ $message = $update?->formattedMessage() ?? $incident->formattedMessage();
+ $url = route('cachet.status-page.incident', $incident).'#'.($update ? 'update-'.$update->id : 'reported');
+ $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 = str_replace(']]>', ']]]]>', $description);
+ @endphp
+ -
+
+ {{ $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..bf64e94a
--- /dev/null
+++ b/src/Http/Controllers/SlackRssController.php
@@ -0,0 +1,135 @@
+ $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 list
+ */
+ private function items(Collection $incidents): array
+ {
+ /** @var list $items */
+ $items = [];
+
+ foreach ($incidents as $incident) {
+ $items[] = [
+ 'incident' => $incident,
+ 'update' => null,
+ 'publishedAt' => $incident->timestamp,
+ ];
+
+ foreach ($incident->updates as $update) {
+ $items[] = [
+ 'incident' => $incident,
+ 'update' => $update,
+ 'publishedAt' => $update->created_at ?? $incident->timestamp,
+ ];
+ }
+ }
+
+ usort($items, fn (array $left, array $right): int => $right['publishedAt']->getTimestamp() <=> $left['publishedAt']->getTimestamp());
+
+ return array_slice($items, 0, self::MAX_ITEMS);
+ }
+}
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..e286dbf0
--- /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 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('[Fixed] Elevated error rates')
+ ->and((string) $xml->channel->item[0]->guid)->toEndWith('#update-'.$resolved->id)
+ ->and((string) $xml->channel->item[0]->description)
+ ->toContain('Status: Fixed')
+ ->toContain('Service has recovered.')
+ ->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('[Reported] Elevated error rates')
+ ->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);
+});