Skip to content
Open
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
1 change: 1 addition & 0 deletions database/factories/IncidentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public function definition(): array
'guid' => fake()->uuid(),
'name' => fake()->sentence,
'status' => IncidentStatusEnum::identified->value,
'baseline_status' => fn (array $attributes) => $attributes['status'] ?? IncidentStatusEnum::identified->value,
'message' => fake()->paragraph,
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

use Cachet\Enums\IncidentStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*
* Incidents now keep a separate baseline status so the current status can
* follow timeline updates without losing what the incident should revert to
* when those updates are edited away or deleted.
*/
public function up(): void
{
Schema::table('incidents', function (Blueprint $table) {
$table->unsignedInteger('baseline_status')->nullable()->after('status');
});

DB::table('incidents')
->whereNotNull('status')
->update(['baseline_status' => DB::raw('status')]);

DB::table('incidents')
->whereNull('status')
->update(['baseline_status' => IncidentStatusEnum::unknown->value]);

Schema::table('incidents', function (Blueprint $table) {
$table->unsignedInteger('baseline_status')->change();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('incidents', function (Blueprint $table) {
$table->dropColumn('baseline_status');
});
}
};
1 change: 1 addition & 0 deletions src/Actions/Incident/CreateIncident.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public function handle(CreateIncidentRequestData $data): Incident
$incident = DB::transaction(function () use ($data): Incident {
return tap(Incident::create(array_merge(
['guid' => Str::uuid()],
['baseline_status' => $data->status],
$data->except('components', 'meta')->toArray()
)), function (Incident $incident) use ($data) {
if ($data->components) {
Expand Down
8 changes: 3 additions & 5 deletions src/Actions/Incident/SyncIncidentStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ public function handle(Incident $incident): Incident
->orderByDesc('id')
->value('status');

if ($status === null) {
return $incident;
}

$status = $status instanceof IncidentStatusEnum ? $status : IncidentStatusEnum::from((int) $status);
$status = $status === null
? $incident->baseline_status
: ($status instanceof IncidentStatusEnum ? $status : IncidentStatusEnum::from((int) $status));

if ($incident->status !== $status) {
$incident->update(['status' => $status]);
Expand Down
8 changes: 7 additions & 1 deletion src/Actions/Incident/UpdateIncident.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ class UpdateIncident
*/
public function handle(Incident $incident, UpdateIncidentRequestData $data): Incident
{
$incident->update($data->except('meta')->toArray());
$attributes = $data->except('meta')->toArray();

if ($data->status !== null) {
$attributes['baseline_status'] = $data->status;
}

$incident->update($attributes);

if ($data->meta !== null) {
$incident->syncMeta($data->meta);
Expand Down
22 changes: 19 additions & 3 deletions src/Data/Requests/IncidentUpdate/EditIncidentUpdateRequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,36 @@
use Cachet\Data\BaseData;
use Cachet\Enums\IncidentStatusEnum;
use Illuminate\Validation\Rule;
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Support\Validation\ValidationContext;

final class EditIncidentUpdateRequestData extends BaseData
{
public function __construct(
public readonly ?IncidentStatusEnum $status = null,
public readonly ?string $message = null,
public readonly Optional|IncidentStatusEnum|null $status = new Optional,
public readonly Optional|string $message = new Optional,
) {}

public static function rules(ValidationContext $context): array
{
return [
'status' => [Rule::enum(IncidentStatusEnum::class)],
'status' => ['nullable', Rule::enum(IncidentStatusEnum::class)],
'message' => ['string'],
];
}

public function toArray(): array
{
$attributes = [];

if (! $this->status instanceof Optional) {
$attributes['status'] = $this->status;
}

if (! $this->message instanceof Optional) {
$attributes['message'] = $this->message;
}

return $attributes;
}
}
15 changes: 11 additions & 4 deletions src/Models/Incident.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
* @property ?int $component_id
* @property string $name
* @property ?IncidentStatusEnum $status
* @property IncidentStatusEnum $baseline_status
* @property string $message
* @property ?Carbon $created_at
* @property ?Carbon $updated_at
Expand Down Expand Up @@ -79,6 +80,7 @@
/** @var array<string, string> */
protected $casts = [
'status' => IncidentStatusEnum::class,
'baseline_status' => IncidentStatusEnum::class,
'visible' => ResourceVisibilityEnum::class,
'stickied' => 'bool',
'scheduled_at' => 'datetime',
Expand All @@ -104,6 +106,7 @@
'component_id',
'name',
'status',
'baseline_status',
'visible',
'stickied',
'notifications',
Expand All @@ -120,6 +123,10 @@
self::creating(function (Incident $model) {
$model->guid = Str::uuid();

if ($model->baseline_status === null) {

Check failure on line 126 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.4 - L13.x - prefer-stable

Strict comparison using === between Cachet\Enums\IncidentStatusEnum and null will always evaluate to false.

Check failure on line 126 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.4 - L11.x - prefer-stable

Strict comparison using === between Cachet\Enums\IncidentStatusEnum and null will always evaluate to false.

Check failure on line 126 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.5 - L13.x - prefer-stable

Strict comparison using === between Cachet\Enums\IncidentStatusEnum and null will always evaluate to false.

Check failure on line 126 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.4 - L12.x - prefer-stable

Strict comparison using === between Cachet\Enums\IncidentStatusEnum and null will always evaluate to false.
$model->baseline_status = $model->status ?? IncidentStatusEnum::unknown;
}

if ($model->published_at === null) {
$model->published_at = $model->freshTimestamp();
$model->published_notified_at = $model->freshTimestamp();
Expand Down Expand Up @@ -256,15 +263,15 @@
/**
* The incident's status.
*
* Retained for backwards compatibility: the status column is canonical and
* is kept in step with the latest status-bearing update at write time, so
* this is simply an alias for it.
* Retained for backwards compatibility: the status column is the current
* status, while `baseline_status` stores what the incident should fall back
* to when its timeline no longer carries a status.
*
* @return Attribute<IncidentStatusEnum|null, never>
*/
protected function latestStatus(): Attribute
{
return Attribute::make(get: fn (): ?IncidentStatusEnum => $this->status);
return Attribute::make(get: fn (): ?IncidentStatusEnum => $this->status ?? $this->baseline_status);

Check failure on line 274 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.4 - L13.x - prefer-stable

Anonymous function never returns null so it can be removed from the return type.

Check failure on line 274 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.4 - L11.x - prefer-stable

Anonymous function never returns null so it can be removed from the return type.

Check failure on line 274 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.5 - L13.x - prefer-stable

Anonymous function never returns null so it can be removed from the return type.

Check failure on line 274 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.4 - L12.x - prefer-stable

Anonymous function never returns null so it can be removed from the return type.

Check failure on line 274 in src/Models/Incident.php

View workflow job for this annotation

GitHub Actions / Static Analysis - P8.3 - L11.x - prefer-lowest

Anonymous function never returns null so it can be removed from the return type.
}

/**
Expand Down
24 changes: 24 additions & 0 deletions tests/Feature/Api/IncidentUpdateTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use Cachet\Actions\Incident\SyncIncidentStatus;
use Cachet\Enums\IncidentStatusEnum;
use Cachet\Enums\ResourceVisibilityEnum;
use Cachet\Models\Incident;
Expand Down Expand Up @@ -232,6 +233,29 @@
]);
});

it('can clear an incident update status and restore the incident baseline', function () {
Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']);

$incident = Incident::factory()->create([
'status' => IncidentStatusEnum::investigating,
'baseline_status' => IncidentStatusEnum::investigating,
]);

$incidentUpdate = $incident->updates()->create([
'status' => IncidentStatusEnum::identified,
'message' => 'We found the issue.',
]);

app(SyncIncidentStatus::class)->handle($incident);

putJson("/status/api/incidents/{$incident->id}/updates/{$incidentUpdate->id}", [
'status' => null,
])->assertOk();

expect($incident->fresh()->status)->toBe(IncidentStatusEnum::investigating)
->and($incidentUpdate->fresh()->status)->toBeNull();
});

it('cannot delete an incident update if not authenticated', function () {
$incidentUpdate = Update::factory()->forIncident()->create();

Expand Down
49 changes: 49 additions & 0 deletions tests/Feature/Database/IncidentBaselineStatusBackfillTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

use Cachet\Enums\IncidentStatusEnum;
use Cachet\Models\Incident;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
* Return the incidents table to the state it was in before the baseline status migration.
*/
function dropIncidentBaselineStatus(): void
{
Schema::table('incidents', function (Blueprint $table) {
$table->dropColumn('baseline_status');
});
}

/**
* Run the baseline status migration over whatever rows are present.
*/
function addIncidentBaselineStatus(): void
{
$migration = require __DIR__.'/../../../database/migrations/2026_07_26_000001_add_baseline_status_to_incidents.php';

$migration->up();
}

it('backfills the incident baseline status from the current status', function () {
$incident = Incident::factory()->create(['status' => IncidentStatusEnum::watching]);

dropIncidentBaselineStatus();
addIncidentBaselineStatus();

expect($incident->fresh()->baseline_status)->toBe(IncidentStatusEnum::watching);
});

it('backfills unknown when an incident has no current status', function () {
$incident = Incident::factory()->create(['status' => IncidentStatusEnum::watching]);

DB::table('incidents')->where('id', $incident->id)->update(['status' => null]);

dropIncidentBaselineStatus();
addIncidentBaselineStatus();

expect($incident->fresh())
->status->toBeNull()
->baseline_status->toBe(IncidentStatusEnum::unknown);
});
3 changes: 2 additions & 1 deletion tests/Unit/Actions/Incident/CreateIncidentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
expect($incident)
->name->toBe($data->name)
->message->toBe($data->message)
->status->toBe($data->status);
->status->toBe($data->status)
->baseline_status->toBe($data->status);

Event::assertDispatched(IncidentCreated::class, fn ($event) => $event->incident->is($incident));
});
Expand Down
16 changes: 16 additions & 0 deletions tests/Unit/Actions/Incident/UpdateIncidentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Cachet\Actions\Incident\UpdateIncident;
use Cachet\Data\Requests\Incident\UpdateIncidentRequestData;
use Cachet\Enums\IncidentStatusEnum;
use Cachet\Events\Incidents\IncidentUpdated;
use Cachet\Models\Incident;

Expand Down Expand Up @@ -30,3 +31,18 @@
Event::assertDispatched(IncidentUpdated::class, fn (IncidentUpdated $event) => $event->incident->is($incident));
Event::assertDispatchedTimes(IncidentUpdated::class, 1);
});

it('updates the incident baseline status when the status changes directly', function () {
$incident = Incident::factory()->create([
'status' => IncidentStatusEnum::investigating,
'baseline_status' => IncidentStatusEnum::investigating,
]);

app(UpdateIncident::class)->handle($incident, UpdateIncidentRequestData::from([
'status' => IncidentStatusEnum::identified,
]));

expect($incident->fresh())
->status->toBe(IncidentStatusEnum::identified)
->baseline_status->toBe(IncidentStatusEnum::identified);
});
22 changes: 22 additions & 0 deletions tests/Unit/Actions/Update/DeleteUpdateTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<?php

use Cachet\Actions\Incident\SyncIncidentStatus;
use Cachet\Actions\Update\DeleteUpdate;
use Cachet\Enums\IncidentStatusEnum;
use Cachet\Models\Incident;
use Cachet\Models\Schedule;
use Cachet\Models\Update;
Expand All @@ -27,3 +29,23 @@
'updateable_id' => $update->updateable_id,
]);
});

it('restores the incident baseline status when deleting its last status-bearing update', function () {
$incident = Incident::factory()->create([
'status' => IncidentStatusEnum::investigating,
'baseline_status' => IncidentStatusEnum::investigating,
]);

$update = $incident->updates()->create([
'status' => IncidentStatusEnum::identified,
'message' => 'Investigating further.',
]);

app(SyncIncidentStatus::class)->handle($incident);

expect($incident->fresh()->status)->toBe(IncidentStatusEnum::identified);

app(DeleteUpdate::class)->handle($update);

expect($incident->fresh()->status)->toBe(IncidentStatusEnum::investigating);
});
25 changes: 25 additions & 0 deletions tests/Unit/Actions/Update/EditUpdateTest.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<?php

use Cachet\Actions\Incident\SyncIncidentStatus;
use Cachet\Actions\Update\EditUpdate;
use Cachet\Data\Requests\IncidentUpdate\EditIncidentUpdateRequestData;
use Cachet\Data\Requests\ScheduleUpdate\EditScheduleUpdateRequestData;
use Cachet\Enums\IncidentStatusEnum;
use Cachet\Models\Incident;
use Cachet\Models\Update;

it('can update an incident update', function () {
Expand Down Expand Up @@ -32,3 +35,25 @@
->message->toBe($data->message)
->status->toBe($update->status);
});

it('restores the incident baseline status when the last status-bearing update is cleared', function () {
$incident = Incident::factory()->create([
'status' => IncidentStatusEnum::investigating,
'baseline_status' => IncidentStatusEnum::investigating,
]);

$update = $incident->updates()->create([
'status' => IncidentStatusEnum::identified,
'message' => 'We found the issue.',
]);

app(SyncIncidentStatus::class)->handle($incident);

expect($incident->fresh()->status)->toBe(IncidentStatusEnum::identified);

app(EditUpdate::class)->handle($update, EditIncidentUpdateRequestData::from([
'status' => null,
]));

expect($incident->fresh()->status)->toBe(IncidentStatusEnum::investigating);
});
Loading
Loading