From 4491e0b60154a214ab0b6bf641f4339b34519536 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Thu, 4 Jun 2026 13:38:01 -0400 Subject: [PATCH 1/9] feat(slo): UI display of SLO state --- config.example.toml | 6 +++ frontend/src/routeTree.gen.ts | 10 ++-- .../components/ActionItemsList.test.tsx | 2 + .../components/ActionItemsList.tsx | 34 +++++++++++++ .../queries/actionItemsQueryOptions.ts | 1 + src/firetower/config.py | 2 + src/firetower/incidents/serializers.py | 17 +++++++ .../incidents/tests/test_action_items.py | 51 +++++++++++++++++++ src/firetower/incidents/views.py | 2 +- src/firetower/settings.py | 2 + 10 files changed, 121 insertions(+), 6 deletions(-) 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/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 694e4127..94f1a568 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -31,8 +31,8 @@ const IncidentIdIndexRoute = IncidentIdIndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/$incidentId/': typeof IncidentIdIndexRoute - '/availability/': typeof AvailabilityIndexRoute + '/$incidentId': typeof IncidentIdIndexRoute + '/availability': typeof AvailabilityIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -47,7 +47,7 @@ export interface FileRoutesById { } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$incidentId/' | '/availability/' + fullPaths: '/' | '/$incidentId' | '/availability' fileRoutesByTo: FileRoutesByTo to: '/' | '/$incidentId' | '/availability' id: '__root__' | '/' | '/$incidentId/' | '/availability/' @@ -71,14 +71,14 @@ declare module '@tanstack/react-router' { '/availability/': { id: '/availability/' path: '/availability' - fullPath: '/availability/' + fullPath: '/availability' preLoaderRoute: typeof AvailabilityIndexRouteImport parentRoute: typeof rootRouteImport } '/$incidentId/': { id: '/$incidentId/' path: '/$incidentId' - fullPath: '/$incidentId/' + fullPath: '/$incidentId' preLoaderRoute: typeof IncidentIdIndexRouteImport parentRoute: typeof rootRouteImport } diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx index e6fe87dd..7cf6cae6 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, }, ]; diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx index 19a93446..bf3de0a3 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -24,7 +24,35 @@ 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 diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + return { + text: `${Math.abs(diffDays)}d overdue`, + className: 'text-content-danger', + }; + } + 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/config.py b/src/firetower/config.py index adb43f9d..be9ef053 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -79,6 +79,8 @@ class LinearConfig: api_key: str = "" action_item_nag_comment_high_priority: str = "" action_item_nag_comment_medium_priority: str = "" + action_item_slo_days_high_priority: int = 14 + action_item_slo_days_medium_priority: int = 28 @deserialize diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index b39301e7..397cb880 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 @@ -666,9 +667,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", 14) + medium = linear.get("ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY", 28) + 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 @@ -681,6 +690,7 @@ class Meta: "assignee_name", "assignee_avatar_url", "url", + "slo_deadline", ] def get_assignee_name(self, obj: ActionItem) -> str | None: @@ -693,6 +703,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 slo_days is None: + 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..ea272373 100644 --- a/src/firetower/incidents/tests/test_action_items.py +++ b/src/firetower/incidents/tests/test_action_items.py @@ -1258,6 +1258,57 @@ 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", + ) + + 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 + 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=28)).isoformat() + assert items["ENG-2"]["slo_deadline"] == expected_medium + + assert items["ENG-3"]["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" ) diff --git a/src/firetower/settings.py b/src/firetower/settings.py index 9d98227e..e4c70002 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -315,6 +315,8 @@ class StatuspageSettings(TypedDict): "SYNC_IDENTIFIERS": config.linear.sync_identifiers, "ACTION_ITEM_NAG_COMMENT_HIGH_PRIORITY": config.linear.action_item_nag_comment_high_priority, "ACTION_ITEM_NAG_COMMENT_MEDIUM_PRIORITY": config.linear.action_item_nag_comment_medium_priority, + "ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY": config.linear.action_item_slo_days_high_priority, + "ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY": config.linear.action_item_slo_days_medium_priority, } if config.linear else None From 3b14d80fa9d089d8d187eae51201729982fa0c6f Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Mon, 8 Jun 2026 15:09:08 -0400 Subject: [PATCH 2/9] fix trailing slashes --- frontend/src/routeTree.gen.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 94f1a568..694e4127 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -31,8 +31,8 @@ const IncidentIdIndexRoute = IncidentIdIndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/$incidentId': typeof IncidentIdIndexRoute - '/availability': typeof AvailabilityIndexRoute + '/$incidentId/': typeof IncidentIdIndexRoute + '/availability/': typeof AvailabilityIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -47,7 +47,7 @@ export interface FileRoutesById { } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$incidentId' | '/availability' + fullPaths: '/' | '/$incidentId/' | '/availability/' fileRoutesByTo: FileRoutesByTo to: '/' | '/$incidentId' | '/availability' id: '__root__' | '/' | '/$incidentId/' | '/availability/' @@ -71,14 +71,14 @@ declare module '@tanstack/react-router' { '/availability/': { id: '/availability/' path: '/availability' - fullPath: '/availability' + fullPath: '/availability/' preLoaderRoute: typeof AvailabilityIndexRouteImport parentRoute: typeof rootRouteImport } '/$incidentId/': { id: '/$incidentId/' path: '/$incidentId' - fullPath: '/$incidentId' + fullPath: '/$incidentId/' preLoaderRoute: typeof IncidentIdIndexRouteImport parentRoute: typeof rootRouteImport } From a11524901bb94ce9932f7f625fc0b8ce5e880bb5 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Mon, 8 Jun 2026 15:22:09 -0400 Subject: [PATCH 3/9] fix bad merge, add ui tests --- .../components/ActionItemsList.test.tsx | 82 +++++++++++++++++++ .../components/ActionItemsList.tsx | 6 ++ src/firetower/config.py | 10 --- 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx index 7cf6cae6..8850b2cb 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx @@ -129,6 +129,88 @@ describe('ActionItemsList', () => { expect(screen.getByText('TEAM-102')).toBeInTheDocument(); }); + it('shows "due today" 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('due today')).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('due today')).not.toBeInTheDocument(); + 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 bf3de0a3..1385282c 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -36,6 +36,12 @@ function getSloLabel(deadline: string): {text: string; className: string} | null className: 'text-content-danger', }; } + if (diffMs <= 0) { + return { + text: 'due today', + className: 'text-content-danger', + }; + } if (diffDays <= 3) { return { text: `${diffDays}d left`, diff --git a/src/firetower/config.py b/src/firetower/config.py index eda8053a..cfcbc543 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -77,15 +77,6 @@ class LinearConfig: project_id: str = "" sync_identifiers: bool = False api_key: str = "" -<<<<<<< HEAD - action_item_nag_comment_high_priority: str = "" - action_item_nag_comment_medium_priority: str = "" - action_item_slo_days_high_priority: int = 14 - action_item_slo_days_medium_priority: int = 28 -||||||| f7852f4 - action_item_nag_comment_high_priority: str = "" - action_item_nag_comment_medium_priority: str = "" -======= action_item_slo_days_high_priority: int = 14 action_item_slo_days_medium_priority: int = 30 action_item_nag_comment_high_priority: str = ( @@ -102,7 +93,6 @@ class LinearConfig: "days from incident creation. Please prioritize this work or close " "out the issue if it is no longer relevant." ) ->>>>>>> main @deserialize From 3bccc2e6de215172e4077023cff83627033e9c34 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Mon, 8 Jun 2026 16:23:42 -0400 Subject: [PATCH 4/9] fixes --- .../components/ActionItemsList.test.tsx | 5 ++--- .../$incidentId/components/ActionItemsList.tsx | 14 +++++--------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx index 8850b2cb..98a5d2b3 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx @@ -129,7 +129,7 @@ describe('ActionItemsList', () => { expect(screen.getByText('TEAM-102')).toBeInTheDocument(); }); - it('shows "due today" when slo_deadline is less than a day overdue', async () => { + 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([ { @@ -146,7 +146,7 @@ describe('ActionItemsList', () => { /> ); - expect(await screen.findByText('due today')).toBeInTheDocument(); + expect(await screen.findByText('1d overdue')).toBeInTheDocument(); }); it('shows overdue label when slo_deadline is more than a day overdue', async () => { @@ -207,7 +207,6 @@ describe('ActionItemsList', () => { ); await screen.findByText('Investigate slow query'); - expect(screen.queryByText('due today')).not.toBeInTheDocument(); expect(screen.queryByText(/overdue/)).not.toBeInTheDocument(); }); diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx index 1385282c..a91062d3 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -27,18 +27,14 @@ const BORDER_CLASS: Record = { 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 diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + const diffDays = Math.floor( + (due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); if (diffDays < 0) { + const absDays = Math.abs(diffDays); return { - text: `${Math.abs(diffDays)}d overdue`, - className: 'text-content-danger', - }; - } - if (diffMs <= 0) { - return { - text: 'due today', + text: `${absDays}d overdue`, className: 'text-content-danger', }; } From 2597690a6789c355e550c8e2ca7168fa90182c66 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Mon, 8 Jun 2026 16:31:13 -0400 Subject: [PATCH 5/9] fix: resolve CI lint failures Remove duplicate LINEAR dict keys (F601) and fix prettier formatting. Co-Authored-By: Claude Opus 4.6 Agent transcript: https://claudescope.sentry.dev/share/ZK2AgPjlKdYNIFl6rOc-fi8usXZ--0tlXG8ZxqUJtZ4 --- .../src/routes/$incidentId/components/ActionItemsList.tsx | 7 ++----- src/firetower/settings.py | 2 -- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx index a91062d3..4e3e4e2f 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -27,9 +27,7 @@ const BORDER_CLASS: Record = { function getSloLabel(deadline: string): {text: string; className: string} | null { const now = new Date(); const due = new Date(deadline); - const diffDays = Math.floor( - (due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) - ); + const diffDays = Math.floor((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (diffDays < 0) { const absDays = Math.abs(diffDays); @@ -52,8 +50,7 @@ function getSloLabel(deadline: string): {text: string; className: string} | null function ActionItemCard({item}: {item: ActionItem}) { const isTerminal = item.status === 'Done' || item.status === 'Canceled'; - const slo = - item.slo_deadline && !isTerminal ? getSloLabel(item.slo_deadline) : null; + const slo = item.slo_deadline && !isTerminal ? getSloLabel(item.slo_deadline) : null; return ( Date: Mon, 8 Jun 2026 16:41:54 -0400 Subject: [PATCH 6/9] fix: align medium SLO fallback with config default and fix overdue rounding Serializer fallback was 28 days for medium priority but LinearConfig defaults to 30. Also fix Math.floor rounding that showed partial-day overdue items as "1d overdue" instead of "0d overdue". Co-Authored-By: Claude Opus 4.6 Agent transcript: https://claudescope.sentry.dev/share/z531hN0j-cWcnmayYgfR23cZ3mB8ELH_d5ibE2w-Ye4 --- .../$incidentId/components/ActionItemsList.test.tsx | 2 +- .../routes/$incidentId/components/ActionItemsList.tsx | 10 ++++++---- src/firetower/incidents/serializers.py | 2 +- src/firetower/incidents/tests/test_action_items.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx index 98a5d2b3..65d2e83c 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx @@ -146,7 +146,7 @@ describe('ActionItemsList', () => { /> ); - expect(await screen.findByText('1d overdue')).toBeInTheDocument(); + expect(await screen.findByText('0d overdue')).toBeInTheDocument(); }); it('shows overdue label when slo_deadline is more than a day overdue', async () => { diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx index 4e3e4e2f..598a2359 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -27,15 +27,17 @@ const BORDER_CLASS: Record = { function getSloLabel(deadline: string): {text: string; className: string} | null { const now = new Date(); const due = new Date(deadline); - const diffDays = Math.floor((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const diffMs = due.getTime() - now.getTime(); + const msPerDay = 1000 * 60 * 60 * 24; - if (diffDays < 0) { - const absDays = Math.abs(diffDays); + if (diffMs < 0) { + const overdueDays = Math.floor(Math.abs(diffMs) / msPerDay); return { - text: `${absDays}d overdue`, + text: `${overdueDays}d overdue`, className: 'text-content-danger', }; } + const diffDays = Math.floor(diffMs / msPerDay); if (diffDays <= 3) { return { text: `${diffDays}d left`, diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index 6edf03d8..638f65b8 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -671,7 +671,7 @@ def create(self, validated_data: dict[str, Any]) -> Tag: def _get_action_item_slo_days() -> dict[int, int]: linear = getattr(settings, "LINEAR", None) or {} high = linear.get("ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY", 14) - medium = linear.get("ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY", 28) + medium = linear.get("ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY", 30) return {1: high, 2: high, 3: medium} diff --git a/src/firetower/incidents/tests/test_action_items.py b/src/firetower/incidents/tests/test_action_items.py index ea272373..3ba31c13 100644 --- a/src/firetower/incidents/tests/test_action_items.py +++ b/src/firetower/incidents/tests/test_action_items.py @@ -1305,7 +1305,7 @@ def test_list_action_items_includes_slo_deadline(self): expected_high = (incident.created_at + timedelta(days=14)).isoformat() assert items["ENG-1"]["slo_deadline"] == expected_high - expected_medium = (incident.created_at + timedelta(days=28)).isoformat() + 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 From e001b340ccaa43a10357ab2530f433ac7fa69820 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Mon, 8 Jun 2026 16:44:52 -0400 Subject: [PATCH 7/9] fix: use LinearConfig as single source of truth for SLO day defaults Co-Authored-By: Claude Opus 4.6 Agent transcript: https://claudescope.sentry.dev/share/0d0EwY3qWFr-is0dzT0y3h-8iKNmu94prHn_TPHYg9U --- src/firetower/incidents/serializers.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index 638f65b8..c980b76e 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -9,6 +9,7 @@ from rest_framework import serializers from firetower.auth.services import get_or_create_user_from_email +from firetower.config import LinearConfig from .hooks import ( on_incident_created, @@ -670,8 +671,14 @@ def create(self, validated_data: dict[str, Any]) -> Tag: def _get_action_item_slo_days() -> dict[int, int]: linear = getattr(settings, "LINEAR", None) or {} - high = linear.get("ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY", 14) - medium = linear.get("ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY", 30) + high = linear.get( + "ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY", + LinearConfig.action_item_slo_days_high_priority, + ) + medium = linear.get( + "ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY", + LinearConfig.action_item_slo_days_medium_priority, + ) return {1: high, 2: high, 3: medium} From 16a69153ac03055b1463bcf99895c83e91ab56a0 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Mon, 8 Jun 2026 16:59:49 -0400 Subject: [PATCH 8/9] fix: return no SLO deadline when LINEAR is not configured When settings.LINEAR is None, SLO days fall back to 0 and the serializer returns null for slo_deadline. The UI already handles null deadlines by not rendering SLO labels. Co-Authored-By: Claude Opus 4.6 Agent transcript: https://claudescope.sentry.dev/share/vusOyvEa0qawftmyz5AgtFwz9J1CStCrRVLTWbbBUzo --- src/firetower/incidents/serializers.py | 13 ++------ .../incidents/tests/test_action_items.py | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index c980b76e..3bde103f 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -9,7 +9,6 @@ from rest_framework import serializers from firetower.auth.services import get_or_create_user_from_email -from firetower.config import LinearConfig from .hooks import ( on_incident_created, @@ -671,14 +670,8 @@ def create(self, validated_data: dict[str, Any]) -> Tag: def _get_action_item_slo_days() -> dict[int, int]: linear = getattr(settings, "LINEAR", None) or {} - high = linear.get( - "ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY", - LinearConfig.action_item_slo_days_high_priority, - ) - medium = linear.get( - "ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY", - LinearConfig.action_item_slo_days_medium_priority, - ) + 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} @@ -713,7 +706,7 @@ def get_assignee_avatar_url(self, obj: ActionItem) -> str | None: def get_slo_deadline(self, obj: ActionItem) -> str | None: slo_days = _get_action_item_slo_days().get(obj.priority) - if slo_days is None: + if not slo_days: return None deadline = obj.incident.created_at + timedelta(days=slo_days) return deadline.isoformat() diff --git a/src/firetower/incidents/tests/test_action_items.py b/src/firetower/incidents/tests/test_action_items.py index 3ba31c13..d70fece2 100644 --- a/src/firetower/incidents/tests/test_action_items.py +++ b/src/firetower/incidents/tests/test_action_items.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from django.conf import settings from django.contrib.auth.models import User from django.utils import timezone from rest_framework.test import APIClient @@ -1294,6 +1295,11 @@ def test_list_action_items_includes_slo_deadline(self): url="https://linear.app/t/ENG-3", ) + settings.LINEAR = { + "ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY": 14, + "ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY": 30, + } + with patch("firetower.incidents.views.sync_action_items_from_linear"): response = self.client.get( f"/api/ui/incidents/{incident.incident_number}/action-items/" @@ -1310,6 +1316,30 @@ def test_list_action_items_includes_slo_deadline(self): 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( username="dev@example.com", From b237a1559fa58fe1a49d07ddf49452fb4f1495d7 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Mon, 8 Jun 2026 19:10:37 -0400 Subject: [PATCH 9/9] fix: use override_settings to avoid test pollution Co-Authored-By: Claude Opus 4.6 Agent transcript: https://claudescope.sentry.dev/share/DEYah1TOo6H7jpeDxvPIFKEcDdtud3U5IqMKV1iiN-c --- src/firetower/incidents/tests/test_action_items.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/firetower/incidents/tests/test_action_items.py b/src/firetower/incidents/tests/test_action_items.py index d70fece2..1412a03e 100644 --- a/src/firetower/incidents/tests/test_action_items.py +++ b/src/firetower/incidents/tests/test_action_items.py @@ -2,8 +2,8 @@ from unittest.mock import patch import pytest -from django.conf import settings from django.contrib.auth.models import User +from django.test import override_settings from django.utils import timezone from rest_framework.test import APIClient @@ -1295,12 +1295,15 @@ def test_list_action_items_includes_slo_deadline(self): url="https://linear.app/t/ENG-3", ) - settings.LINEAR = { + linear_settings = { "ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY": 14, "ACTION_ITEM_SLO_DAYS_MEDIUM_PRIORITY": 30, } - with patch("firetower.incidents.views.sync_action_items_from_linear"): + 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/" )