Skip to content
Merged
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
6 changes: 6 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.

[notion]
integration_token = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
},
];

Expand Down Expand Up @@ -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(
<ActionItemsList
incidentId="INC-1"
linearUrl="https://linear.app/team/issue/INC-1"
/>
);

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(
<ActionItemsList
incidentId="INC-1"
linearUrl="https://linear.app/team/issue/INC-1"
/>
);

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(
<ActionItemsList
incidentId="INC-1"
linearUrl="https://linear.app/team/issue/INC-1"
/>
);

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(
<ActionItemsList
incidentId="INC-1"
linearUrl="https://linear.app/team/issue/INC-1"
/>
);

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'));

Expand Down
35 changes: 35 additions & 0 deletions frontend/src/routes/$incidentId/components/ActionItemsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,36 @@ const BORDER_CLASS: Record<ActionItemStatus, string> = {
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',
};
Comment thread
cursor[bot] marked this conversation as resolved.
}
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 (
<a
href={item.url}
Expand Down Expand Up @@ -54,6 +83,12 @@ function ActionItemCard({item}: {item: ActionItem}) {
</span>
</>
) : null}
{slo ? (
<>
<span aria-hidden="true">&middot;</span>
<span className={slo.className}>{slo.text}</span>
</>
) : null}
</div>
</div>
<PriorityIcon priority={item.priority} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ActionItemSchema>;
Expand Down
17 changes: 17 additions & 0 deletions src/firetower/incidents/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from datetime import timedelta
from typing import Any

from django.conf import settings
Expand Down Expand Up @@ -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}
Comment thread
cursor[bot] marked this conversation as resolved.


class ActionItemSerializer(serializers.ModelSerializer):
assignee_name = serializers.SerializerMethodField()
assignee_avatar_url = serializers.SerializerMethodField()
slo_deadline = serializers.SerializerMethodField()

class Meta:
model = ActionItem
Expand All @@ -682,6 +691,7 @@ class Meta:
"assignee_name",
"assignee_avatar_url",
"url",
"slo_deadline",
]

def get_assignee_name(self, obj: ActionItem) -> str | None:
Expand All @@ -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]:
Expand Down
84 changes: 84 additions & 0 deletions src/firetower/incidents/tests/test_action_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}

Comment thread
github-actions[bot] marked this conversation as resolved.
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
Comment thread
cursor[bot] marked this conversation as resolved.

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(
Expand Down
2 changes: 1 addition & 1 deletion src/firetower/incidents/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
Loading