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" )