diff --git a/config.example.toml b/config.example.toml
index 57452dbc..7fae66da 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -48,6 +48,12 @@ team_id = ""
project_id = ""
# When true, claim the Linear issue matching the incident number (e.g. INC-2160) instead of creating a new one.
sync_identifiers = false
+# SLO deadlines (days from incident creation) for action items by priority tier.
+# High priority covers Linear priorities 1 (Urgent) and 2 (High).
+# Medium priority covers Linear priority 3 (Medium).
+# Priority 4 (Low) and 0 (No priority) have no SLO.
+action_item_slo_days_high_priority = 14
+action_item_slo_days_medium_priority = 28
[notion]
integration_token = ""
diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx
index e6fe87dd..65d2e83c 100644
--- a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx
+++ b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx
@@ -43,6 +43,7 @@ const mockActionItems: ActionItem[] = [
assignee_name: 'Alice Smith',
assignee_avatar_url: null,
url: 'https://linear.app/team/issue/TEAM-101',
+ slo_deadline: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
},
{
linear_identifier: 'TEAM-102',
@@ -52,6 +53,7 @@ const mockActionItems: ActionItem[] = [
assignee_name: null,
assignee_avatar_url: null,
url: 'https://linear.app/team/issue/TEAM-102',
+ slo_deadline: null,
},
];
@@ -127,6 +129,87 @@ describe('ActionItemsList', () => {
expect(screen.getByText('TEAM-102')).toBeInTheDocument();
});
+ it('shows overdue label when slo_deadline is less than a day overdue', async () => {
+ const halfDayAgo = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString();
+ mockApiGet.mockResolvedValue([
+ {
+ ...mockActionItems[0],
+ slo_deadline: halfDayAgo,
+ status: 'Todo',
+ },
+ ]);
+
+ renderWithProviders(
+
+ );
+
+ expect(await screen.findByText('0d overdue')).toBeInTheDocument();
+ });
+
+ it('shows overdue label when slo_deadline is more than a day overdue', async () => {
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
+ mockApiGet.mockResolvedValue([
+ {
+ ...mockActionItems[0],
+ slo_deadline: twoDaysAgo,
+ status: 'Todo',
+ },
+ ]);
+
+ renderWithProviders(
+
+ );
+
+ expect(await screen.findByText(/overdue/)).toBeInTheDocument();
+ });
+
+ it('shows warning-styled days left when slo_deadline is within 3 days', async () => {
+ const twoDaysFromNow = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
+ mockApiGet.mockResolvedValue([
+ {
+ ...mockActionItems[0],
+ slo_deadline: twoDaysFromNow,
+ status: 'Todo',
+ },
+ ]);
+
+ renderWithProviders(
+
+ );
+
+ expect(await screen.findByText(/\dd left/)).toBeInTheDocument();
+ });
+
+ it('does not show slo label for terminal action items', async () => {
+ const halfDayAgo = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString();
+ mockApiGet.mockResolvedValue([
+ {
+ ...mockActionItems[0],
+ slo_deadline: halfDayAgo,
+ status: 'Done',
+ },
+ ]);
+
+ renderWithProviders(
+
+ );
+
+ await screen.findByText('Investigate slow query');
+ expect(screen.queryByText(/overdue/)).not.toBeInTheDocument();
+ });
+
it('renders fallback when the action items query fails, with header + sync button still visible', async () => {
mockApiGet.mockRejectedValue(new Error('boom'));
diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx
index 19a93446..598a2359 100644
--- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx
+++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx
@@ -24,7 +24,36 @@ const BORDER_CLASS: Record = {
Canceled: 'border-neutral-muted',
};
+function getSloLabel(deadline: string): {text: string; className: string} | null {
+ const now = new Date();
+ const due = new Date(deadline);
+ const diffMs = due.getTime() - now.getTime();
+ const msPerDay = 1000 * 60 * 60 * 24;
+
+ if (diffMs < 0) {
+ const overdueDays = Math.floor(Math.abs(diffMs) / msPerDay);
+ return {
+ text: `${overdueDays}d overdue`,
+ className: 'text-content-danger',
+ };
+ }
+ const diffDays = Math.floor(diffMs / msPerDay);
+ if (diffDays <= 3) {
+ return {
+ text: `${diffDays}d left`,
+ className: 'text-content-warning',
+ };
+ }
+ return {
+ text: `${diffDays}d left`,
+ className: 'text-content-secondary',
+ };
+}
+
function ActionItemCard({item}: {item: ActionItem}) {
+ const isTerminal = item.status === 'Done' || item.status === 'Canceled';
+ const slo = item.slo_deadline && !isTerminal ? getSloLabel(item.slo_deadline) : null;
+
return (
>
) : null}
+ {slo ? (
+ <>
+ ·
+ {slo.text}
+ >
+ ) : null}
diff --git a/frontend/src/routes/$incidentId/queries/actionItemsQueryOptions.ts b/frontend/src/routes/$incidentId/queries/actionItemsQueryOptions.ts
index c73a8aab..102aeec0 100644
--- a/frontend/src/routes/$incidentId/queries/actionItemsQueryOptions.ts
+++ b/frontend/src/routes/$incidentId/queries/actionItemsQueryOptions.ts
@@ -12,6 +12,7 @@ const ActionItemSchema = z.object({
assignee_name: z.string().nullable(),
assignee_avatar_url: z.string().nullable(),
url: z.string(),
+ slo_deadline: z.string().nullable(),
});
export type ActionItem = z.infer;
diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py
index de16ee16..3bde103f 100644
--- a/src/firetower/incidents/serializers.py
+++ b/src/firetower/incidents/serializers.py
@@ -1,4 +1,5 @@
from dataclasses import dataclass
+from datetime import timedelta
from typing import Any
from django.conf import settings
@@ -667,9 +668,17 @@ def create(self, validated_data: dict[str, Any]) -> Tag:
raise serializers.ValidationError(e.message_dict)
+def _get_action_item_slo_days() -> dict[int, int]:
+ linear = getattr(settings, "LINEAR", None) or {}
+ high = linear.get("ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY", 0)
+ medium = linear.get("ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY", 0)
+ return {1: high, 2: high, 3: medium}
+
+
class ActionItemSerializer(serializers.ModelSerializer):
assignee_name = serializers.SerializerMethodField()
assignee_avatar_url = serializers.SerializerMethodField()
+ slo_deadline = serializers.SerializerMethodField()
class Meta:
model = ActionItem
@@ -682,6 +691,7 @@ class Meta:
"assignee_name",
"assignee_avatar_url",
"url",
+ "slo_deadline",
]
def get_assignee_name(self, obj: ActionItem) -> str | None:
@@ -694,6 +704,13 @@ def get_assignee_avatar_url(self, obj: ActionItem) -> str | None:
return obj.assignee.userprofile.avatar_url or None
return None
+ def get_slo_deadline(self, obj: ActionItem) -> str | None:
+ slo_days = _get_action_item_slo_days().get(obj.priority)
+ if not slo_days:
+ return None
+ deadline = obj.incident.created_at + timedelta(days=slo_days)
+ return deadline.isoformat()
+
class IncidentOrRedirectReadSerializer(serializers.Serializer):
def to_representation(self, instance: IncidentOrRedirect) -> dict[str, Any]:
diff --git a/src/firetower/incidents/tests/test_action_items.py b/src/firetower/incidents/tests/test_action_items.py
index a1c91ff0..1412a03e 100644
--- a/src/firetower/incidents/tests/test_action_items.py
+++ b/src/firetower/incidents/tests/test_action_items.py
@@ -3,6 +3,7 @@
import pytest
from django.contrib.auth.models import User
+from django.test import override_settings
from django.utils import timezone
from rest_framework.test import APIClient
@@ -1258,6 +1259,89 @@ def test_list_action_items(self):
assert response.data[0]["linear_identifier"] == "ENG-1"
assert response.data[0]["title"] == "Task 1"
assert response.data[0]["relation_type"] == "child"
+ assert response.data[0]["slo_deadline"] is None
+
+ def test_list_action_items_includes_slo_deadline(self):
+ incident = Incident.objects.create(
+ title="Test Incident",
+ status=IncidentStatus.ACTIVE,
+ severity=IncidentSeverity.P1,
+ )
+ ActionItem.objects.create(
+ incident=incident,
+ linear_issue_id="id-1",
+ linear_identifier="ENG-1",
+ title="High priority task",
+ status=ActionItemStatus.TODO,
+ priority=2,
+ url="https://linear.app/t/ENG-1",
+ )
+ ActionItem.objects.create(
+ incident=incident,
+ linear_issue_id="id-2",
+ linear_identifier="ENG-2",
+ title="Medium priority task",
+ status=ActionItemStatus.TODO,
+ priority=3,
+ url="https://linear.app/t/ENG-2",
+ )
+ ActionItem.objects.create(
+ incident=incident,
+ linear_issue_id="id-3",
+ linear_identifier="ENG-3",
+ title="Low priority task",
+ status=ActionItemStatus.TODO,
+ priority=4,
+ url="https://linear.app/t/ENG-3",
+ )
+
+ linear_settings = {
+ "ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY": 14,
+ "ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY": 30,
+ }
+
+ with (
+ override_settings(LINEAR=linear_settings),
+ patch("firetower.incidents.views.sync_action_items_from_linear"),
+ ):
+ response = self.client.get(
+ f"/api/ui/incidents/{incident.incident_number}/action-items/"
+ )
+
+ assert response.status_code == 200
+ items = {item["linear_identifier"]: item for item in response.data}
+
+ expected_high = (incident.created_at + timedelta(days=14)).isoformat()
+ assert items["ENG-1"]["slo_deadline"] == expected_high
+
+ expected_medium = (incident.created_at + timedelta(days=30)).isoformat()
+ assert items["ENG-2"]["slo_deadline"] == expected_medium
+
+ assert items["ENG-3"]["slo_deadline"] is None
+
+ def test_list_action_items_no_slo_deadline_without_linear(self):
+ incident = Incident.objects.create(
+ title="Test Incident",
+ status=IncidentStatus.ACTIVE,
+ severity=IncidentSeverity.P1,
+ )
+ ActionItem.objects.create(
+ incident=incident,
+ linear_issue_id="id-1",
+ linear_identifier="ENG-1",
+ title="High priority task",
+ status=ActionItemStatus.TODO,
+ priority=2,
+ url="https://linear.app/t/ENG-1",
+ )
+
+ with patch("firetower.incidents.views.sync_action_items_from_linear"):
+ response = self.client.get(
+ f"/api/ui/incidents/{incident.incident_number}/action-items/"
+ )
+
+ assert response.status_code == 200
+ assert response.data[0]["slo_deadline"] is None
def test_list_action_items_includes_assignee_info(self):
user = User.objects.create_user(
diff --git a/src/firetower/incidents/views.py b/src/firetower/incidents/views.py
index 0b27fdd8..00af7196 100644
--- a/src/firetower/incidents/views.py
+++ b/src/firetower/incidents/views.py
@@ -365,7 +365,7 @@ class ActionItemListView(generics.ListAPIView):
def get_queryset(self) -> QuerySet[ActionItem]:
return (
self._get_incident()
- .action_items.select_related("assignee__userprofile")
+ .action_items.select_related("incident", "assignee__userprofile")
.order_by(
Case(When(priority=0, then=5), default=F("priority")), "created_at"
)