diff --git a/README.md b/README.md index 2c3a73b2..48811297 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config.example.toml b/config.example.toml index f1575ef9..25568dd3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 diff --git a/src/firetower/config.py b/src/firetower/config.py index 38ff8342..536d4e0e 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -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 = "" diff --git a/src/firetower/incidents/services.py b/src/firetower/incidents/services.py index 39d70520..cb33df28 100644 --- a/src/firetower/incidents/services.py +++ b/src/firetower/incidents/services.py @@ -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]: @@ -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) diff --git a/src/firetower/incidents/tests/test_action_item_labels.py b/src/firetower/incidents/tests/test_action_item_labels.py new file mode 100644 index 00000000..b8b75dda --- /dev/null +++ b/src/firetower/incidents/tests/test_action_item_labels.py @@ -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() diff --git a/src/firetower/integrations/services/linear.py b/src/firetower/integrations/services/linear.py index 74ccf0f5..34d35980 100644 --- a/src/firetower/integrations/services/linear.py +++ b/src/firetower/integrations/services/linear.py @@ -46,6 +46,11 @@ priority createdAt slaBreachesAt + labels { + nodes { + id + } + } state { id type @@ -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 @@ -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!) { diff --git a/src/firetower/integrations/tests/test_linear_service.py b/src/firetower/integrations/tests/test_linear_service.py index 88e5fadf..fbbb4e99 100644 --- a/src/firetower/integrations/tests/test_linear_service.py +++ b/src/firetower/integrations/tests/test_linear_service.py @@ -779,6 +779,7 @@ 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", @@ -786,6 +787,19 @@ def test_parses_full_issue(self, linear_service): "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"), @@ -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( diff --git a/src/firetower/settings.py b/src/firetower/settings.py index 7315d51b..a71d0ab2 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -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,