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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ frontend/ # React frontend
sdk/ # Python SDK
```

## Incident Action Labels

Set `action_item_label_id` in the `[linear]` section of `config.toml` to the workspace-wide `incident-action` label's ID to enable automatic labeling during action item sync, including pre-existing issues adopted as follow-ups. Other labels are preserved. Labeling is skipped when the value is empty or omitted.

## Scheduled Tasks

Scheduled tasks are stored as database objects and are managed via migrations.
Expand Down
1 change: 1 addition & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ client_secret = "" # Overridable via LINEAR_CLIENT_SECRET
action_item_sync_throttle_seconds = 300
team_id = ""
project_id = ""
action_item_label_id = ""
# When true, claim the Linear issue matching the incident number (e.g. INC-2160) instead of creating a new one.
sync_identifiers = false
# When true (and sync_identifiers is true), allocate incident ids by claiming or
Expand Down
1 change: 1 addition & 0 deletions src/firetower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class LinearConfig:
action_item_sync_throttle_seconds: int = 300
team_id: str = ""
project_id: str = ""
action_item_label_id: str = ""
sync_identifiers: bool = False
adopt_on_create: bool = False
api_key: str = ""
Expand Down
27 changes: 27 additions & 0 deletions src/firetower/incidents/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,29 @@ def _set_action_item_sla(linear_service: LinearService, issue: dict) -> None:
logger.error("Failed to set Linear SLA for action item %s", issue["identifier"])


def _set_action_item_labels(
linear_service: LinearService, issues: list[dict]
) -> list[str]:
label_id = (settings.LINEAR or {}).get("ACTION_ITEM_LABEL_ID")
if not issues or not label_id:
return []

errors = []
for issue in issues:
if label_id in issue.get("label_ids", []):
continue
try:
success = linear_service.add_issue_label(issue["id"], label_id)
except Exception:
logger.exception("Failed to label action item %s", issue["identifier"])
success = False
if not success:
error = f"Failed to label action item {issue['identifier']}"
logger.warning(error)
errors.append(error)
return errors


def _resolve_assignees(
issues: dict[str, dict],
) -> dict[str, User | None]:
Expand Down Expand Up @@ -390,6 +413,10 @@ def sync_action_items_from_linear(
incident.action_items_last_synced_at = timezone.now()
incident.save(update_fields=["action_items_last_synced_at"])

stats.errors.extend(
_set_action_item_labels(linear_service, list(all_issues.values()))
)

if incident.severity in (IncidentSeverity.P0, IncidentSeverity.P1):
for issue in all_issues.values():
_set_action_item_sla(linear_service, issue)
Expand Down
133 changes: 133 additions & 0 deletions src/firetower/incidents/tests/test_action_item_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from datetime import timedelta
from unittest.mock import MagicMock, call, patch

import pytest
from django.utils import timezone

from firetower.incidents.models import (
ActionItem,
Incident,
IncidentSeverity,
IncidentStatus,
)
from firetower.incidents.services import sync_action_items_from_linear
from firetower.integrations.services.linear import LinearService

pytestmark = pytest.mark.django_db


@pytest.fixture
def issue():
return {
"id": "issue-1",
"identifier": "ENG-1",
"title": "Pre-existing follow-up",
"url": "https://linear.app/issue/ENG-1",
"status": "Todo",
"priority": 0,
"relation_type": "child",
"created_at": timezone.now() - timedelta(days=365),
"label_ids": ["unrelated-label"],
}


@pytest.fixture
def service(settings, issue):
settings.LINEAR = {
"TEAM_ID": "incident-team",
"ACTION_ITEM_LABEL_ID": "action-label",
}
service = MagicMock(spec=LinearService)
service.get_child_issues.return_value = [issue]
service.add_issue_label.return_value = True
with patch(
"firetower.incidents.services._get_linear_service", return_value=service
):
yield service


@pytest.fixture
def incident():
return Incident.objects.create(
title="Incident",
severity=IncidentSeverity.P2,
status=IncidentStatus.POSTMORTEM,
linear_parent_issue_id="parent-1",
)


class TestActionItemLabels:
@pytest.mark.parametrize("already_synced", [False, True])
def test_labels_newly_adopted_and_cached_items(
self, incident, issue, service, already_synced
):
if already_synced:
ActionItem.objects.create(
incident=incident,
linear_issue_id=issue["id"],
linear_identifier=issue["identifier"],
title="Old title",
url=issue["url"],
)

stats = sync_action_items_from_linear(incident, force=True)

assert stats.errors == []
item = incident.action_items.get()
assert item.created_at == issue["created_at"]
service.add_issue_label.assert_called_once_with("issue-1", "action-label")

@pytest.mark.parametrize("severity", IncidentSeverity.values)
@pytest.mark.parametrize("status", ["Todo", "In Progress", "Done", "Canceled"])
def test_labels_every_severity_and_action_status(
self, incident, issue, service, severity, status
):
incident.severity = severity
issue["status"] = status
sync_action_items_from_linear(incident, force=True)
service.add_issue_label.assert_called_once_with("issue-1", "action-label")

def test_skips_already_labeled_items(self, incident, issue, service):
issue["label_ids"].append("action-label")
stats = sync_action_items_from_linear(incident, force=True)
assert stats.created == 1
assert stats.errors == []
service.add_issue_label.assert_not_called()

@pytest.mark.parametrize("failure", [False, RuntimeError("Label update failed")])
def test_label_failure_does_not_block_sync_or_other_items(
self, incident, issue, service, failure
):
service.get_child_issues.return_value = [
issue,
{**issue, "id": "issue-2", "identifier": "ENG-2"},
]
service.add_issue_label.side_effect = [failure, True]

stats = sync_action_items_from_linear(incident, force=True)

assert stats.created == 2
assert stats.errors == ["Failed to label action item ENG-1"]
assert incident.action_items.count() == 2
service.add_issue_label.assert_has_calls(
[call("issue-1", "action-label"), call("issue-2", "action-label")]
)

service.add_issue_label.side_effect = None
stats = sync_action_items_from_linear(incident, force=True)
assert stats.errors == []
assert stats.updated == 2

def test_skips_labeling_without_configured_id(self, settings, incident, service):
settings.LINEAR.pop("ACTION_ITEM_LABEL_ID")

stats = sync_action_items_from_linear(incident, force=True)

assert stats.created == 1
assert stats.errors == []
service.add_issue_label.assert_not_called()

def test_does_not_label_without_children(self, incident, service):
service.get_child_issues.return_value = []
sync_action_items_from_linear(incident, force=True)
service.add_issue_label.assert_not_called()
22 changes: 22 additions & 0 deletions src/firetower/integrations/services/linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
priority
createdAt
slaBreachesAt
labels {
nodes {
id
}
}
state {
id
type
Expand Down Expand Up @@ -406,6 +411,9 @@ def _parse_issue(
"url": issue["url"],
"status": status,
"priority": issue.get("priority", 0),
"label_ids": [
label["id"] for label in (issue.get("labels") or {}).get("nodes", [])
],
"created_at": parse_datetime(created_at) if created_at else None,
"sla_breaches_at": (
parse_datetime(sla_breaches_at) if sla_breaches_at else None
Expand Down Expand Up @@ -527,6 +535,20 @@ def create_issue(
"url": issue["url"],
}

def add_issue_label(self, issue_id: str, label_id: str) -> bool:
"""Add a label atomically without replacing any existing issue labels."""
mutation = """
mutation($id: String!, $labelId: String!) {
issueAddLabel(id: $id, labelId: $labelId) {
success
}
}
"""
data = self._graphql(mutation, {"id": issue_id, "labelId": label_id})
if not data:
return False
return data.get("issueAddLabel", {}).get("success", False)

def create_attachment(self, issue_id: str, url: str, title: str) -> bool:
mutation = """
mutation($input: AttachmentCreateInput!) {
Expand Down
33 changes: 33 additions & 0 deletions src/firetower/integrations/tests/test_linear_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -779,13 +779,27 @@ def test_parses_full_issue(self, linear_service):
"url": "https://linear.app/team/issue/ENG-123",
"status": "In Progress",
"priority": 2,
"label_ids": [],
"created_at": datetime(2026, 10, 16, 12, 30, tzinfo=UTC),
"sla_breaches_at": datetime(2026, 10, 30, 12, 30, tzinfo=UTC),
"assignee_email": "alice@sentry.io",
"assignee_linear_id": "user-1",
"relation_type": "child",
}

def test_parses_existing_label_ids(self, linear_service):
issue = {
"id": "issue-1",
"identifier": "ENG-1",
"title": "An existing issue",
"url": "https://linear.app/issue/ENG-1",
"labels": {"nodes": [{"id": "existing-label"}, {"id": "action-label"}]},
}
assert linear_service._parse_issue(issue)["label_ids"] == [
"existing-label",
"action-label",
]

def test_maps_all_state_types(self, linear_service):
for state_type, expected_status in [
("triage", "Todo"),
Expand All @@ -806,6 +820,25 @@ def test_maps_all_state_types(self, linear_service):
assert linear_service._parse_issue(issue)["status"] == expected_status


class TestIssueLabels:
def test_adds_label_without_replacing_existing_labels(self, linear_service):
with patch.object(
linear_service,
"_graphql",
return_value={"issueAddLabel": {"success": True}},
) as graphql:
assert linear_service.add_issue_label("issue-1", "action-label") is True

query, variables = graphql.call_args.args
assert "issueAddLabel" in query
assert variables == {"id": "issue-1", "labelId": "action-label"}

@pytest.mark.parametrize("response", [None, {"issueAddLabel": {"success": False}}])
def test_reports_addition_failure(self, linear_service, response):
with patch.object(linear_service, "_graphql", return_value=response):
assert linear_service.add_issue_label("issue-1", "action-label") is False


class TestGetChildIssues:
def test_paginates_through_multiple_pages(self, linear_service):
with patch.object(
Expand Down
1 change: 1 addition & 0 deletions src/firetower/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ class StatuspageSettings(TypedDict):
"API_KEY": config.linear.api_key,
"TEAM_ID": config.linear.team_id,
"PROJECT_ID": config.linear.project_id,
"ACTION_ITEM_LABEL_ID": config.linear.action_item_label_id,
"SYNC_IDENTIFIERS": config.linear.sync_identifiers,
"INCIDENT_ADOPT_ON_CREATE": config.linear.adopt_on_create,
"ACTION_ITEM_SLO_DAYS_HIGH_PRIORITY": config.linear.action_item_slo_days_high_priority,
Expand Down
Loading