diff --git a/config.ci.toml b/config.ci.toml index 6975ea64..68e93f6d 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -1,7 +1,7 @@ project_key = "INC" +firetower_base_url = "http://localhost:5173" django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69" sentry_dsn = "" -firetower_base_url = "http://localhost:5173" # This is the actual essential part. Values match the container set up by GHA [postgres] diff --git a/config.example.toml b/config.example.toml index 2496a4f8..21118f11 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,7 +1,7 @@ project_key = "INC" +firetower_base_url = "http://localhost:5173" django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69" sentry_dsn = "https://your-sentry-dsn@o1.ingest.us.sentry.io/project-id" -firetower_base_url = "http://localhost:5173" [postgres] db = "firetower" @@ -28,6 +28,10 @@ incident_guide_message = "This is the message posted whenever a new incident sla iap_enabled = false iap_audience = "" +[linear] +api_key = "" +action_item_sync_throttle_seconds = 300 + [datadog] api_key = "" app_key = "" diff --git a/src/firetower/config.py b/src/firetower/config.py index 052024d6..f4a4deb4 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -45,6 +45,12 @@ class SlackConfig: incident_guide_message: str = "" +@deserialize +class LinearConfig: + api_key: str + action_item_sync_throttle_seconds: int + + @deserialize class AuthConfig: iap_enabled: bool @@ -61,12 +67,13 @@ class ConfigFile: datadog: DatadogConfig | None jira: JIRAConfig slack: SlackConfig + linear: LinearConfig | None auth: AuthConfig project_key: str + firetower_base_url: str django_secret_key: str sentry_dsn: str - firetower_base_url: str hooks_enabled: bool = ( False # TODO: remove after hooks migration is complete and always enable ) @@ -132,10 +139,11 @@ def __init__(self) -> None: iap_enabled=False, iap_audience="", ) + self.linear = None self.datadog = None self.project_key = "" + self.firetower_base_url = "" self.django_secret_key = "" self.sentry_dsn = "" self.pinned_regions: list[str] = [] - self.firetower_base_url = "" self.hooks_enabled = False diff --git a/src/firetower/incidents/admin.py b/src/firetower/incidents/admin.py index 50b03d09..0db98a2c 100644 --- a/src/firetower/incidents/admin.py +++ b/src/firetower/incidents/admin.py @@ -5,7 +5,10 @@ from django.http import HttpRequest from .models import ExternalLink, Incident, Tag -from .services import sync_incident_participants_from_slack +from .services import ( + sync_action_items_from_linear, + sync_incident_participants_from_slack, +) class ExternalLinkInline(admin.TabularInline): @@ -36,7 +39,11 @@ class IncidentAdmin(admin.ModelAdmin): "impact_type_tags", ] - actions = ["sync_participants_from_slack", "clear_milestones"] + actions = [ + "sync_participants_from_slack", + "sync_action_items", + "clear_milestones", + ] inlines = [ExternalLinkInline] @@ -112,6 +119,36 @@ def sync_participants_from_slack( self.message_user(request, f"Participant sync: {', '.join(message_parts)}") + @admin.action(description="Sync action items from Linear") + def sync_action_items( + self, request: HttpRequest, queryset: QuerySet[Incident] + ) -> None: + success_count = 0 + skipped_count = 0 + error_count = 0 + + for incident in queryset: + try: + stats = sync_action_items_from_linear(incident, force=True) + if stats.errors: + error_count += 1 + elif stats.skipped: + skipped_count += 1 + else: + success_count += 1 + except Exception: + error_count += 1 + + message_parts = [] + if success_count: + message_parts.append(f"{success_count} synced successfully") + if skipped_count: + message_parts.append(f"{skipped_count} skipped") + if error_count: + message_parts.append(f"{error_count} failed") + + self.message_user(request, f"Action item sync: {', '.join(message_parts)}") + @admin.action(description="Clear all milestones") def clear_milestones( self, request: HttpRequest, queryset: QuerySet[Incident] diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index b6872abf..4e8c4ee2 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -28,7 +28,7 @@ def _build_channel_topic( incident: Incident, captain_slack_id: str | None = None ) -> str: base_url = settings.FIRETOWER_BASE_URL - incident_url = f"{base_url}/{incident.incident_number}" + incident_url = f"{base_url}/{incident.incident_number}/" ic_part = "" if incident.captain: @@ -59,7 +59,7 @@ def _build_channel_topic( def _build_incident_url(incident: Incident) -> str: - return f"{settings.FIRETOWER_BASE_URL}/{incident.incident_number}" + return f"{settings.FIRETOWER_BASE_URL}/{incident.incident_number}/" def _get_channel_id(incident: Incident) -> str | None: diff --git a/src/firetower/incidents/management/__init__.py b/src/firetower/incidents/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/incidents/management/commands/__init__.py b/src/firetower/incidents/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/incidents/migrations/0015_add_action_item_model.py b/src/firetower/incidents/migrations/0015_add_action_item_model.py new file mode 100644 index 00000000..41bb068a --- /dev/null +++ b/src/firetower/incidents/migrations/0015_add_action_item_model.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.12 on 2026-04-01 19:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "0014_add_total_downtime"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="incident", + name="action_items_last_synced_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.CreateModel( + name="ActionItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("linear_issue_id", models.CharField(max_length=255, unique=True)), + ("linear_identifier", models.CharField(max_length=25)), + ("title", models.CharField(max_length=500)), + ( + "status", + models.CharField( + choices=[ + ("Todo", "Todo"), + ("In Progress", "In Progress"), + ("Done", "Done"), + ("Cancelled", "Cancelled"), + ], + default="Todo", + max_length=20, + ), + ), + ("url", models.URLField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "assignee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="action_items", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "incident", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="action_items", + to="incidents.incident", + ), + ), + ], + options={ + "ordering": ["created_at"], + }, + ), + ] diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index 26ba48cf..ddff7a60 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -184,6 +184,7 @@ class Incident(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) participants_last_synced_at = models.DateTimeField(null=True, blank=True) + action_items_last_synced_at = models.DateTimeField(null=True, blank=True) # Milestone timestamps (for postmortem) total_downtime = models.IntegerField( @@ -326,6 +327,41 @@ def __str__(self) -> str: return f"{self.incident_number}: {self.title}" +class ActionItemStatus(models.TextChoices): + TODO = "Todo", "Todo" + IN_PROGRESS = "In Progress", "In Progress" + DONE = "Done", "Done" + CANCELLED = "Cancelled", "Cancelled" + + +class ActionItem(models.Model): + incident = models.ForeignKey( + "Incident", on_delete=models.CASCADE, related_name="action_items" + ) + linear_issue_id = models.CharField(max_length=255, unique=True) + linear_identifier = models.CharField(max_length=25) + title = models.CharField(max_length=500) + status = models.CharField( + max_length=20, choices=ActionItemStatus.choices, default=ActionItemStatus.TODO + ) + assignee = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="action_items", + ) + url = models.URLField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["created_at"] + + def __str__(self) -> str: + return f"{self.linear_identifier}: {self.title}" + + class ExternalLink(models.Model): """ Links to external resources related to an incident. diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index 92ce3c12..48bfcd33 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -19,6 +19,7 @@ ) from .models import ( USER_ADDABLE_TAG_TYPES, + ActionItem, ExternalLink, ExternalLinkType, Incident, @@ -637,6 +638,32 @@ def create(self, validated_data: dict[str, Any]) -> Tag: raise serializers.ValidationError(e.message_dict) +class ActionItemSerializer(serializers.ModelSerializer): + assignee_name = serializers.SerializerMethodField() + assignee_avatar_url = serializers.SerializerMethodField() + + class Meta: + model = ActionItem + fields = [ + "linear_identifier", + "title", + "status", + "assignee_name", + "assignee_avatar_url", + "url", + ] + + def get_assignee_name(self, obj: ActionItem) -> str | None: + if obj.assignee: + return obj.assignee.get_full_name() or obj.assignee.username + return None + + def get_assignee_avatar_url(self, obj: ActionItem) -> str | None: + if obj.assignee and hasattr(obj.assignee, "userprofile"): + return obj.assignee.userprofile.avatar_url or None + return None + + class IncidentOrRedirectReadSerializer(serializers.Serializer): def to_representation(self, instance: IncidentOrRedirect) -> dict[str, Any]: serializer = IncidentDetailUISerializer() diff --git a/src/firetower/incidents/services.py b/src/firetower/incidents/services.py index 028a88d3..81af49fe 100644 --- a/src/firetower/incidents/services.py +++ b/src/firetower/incidents/services.py @@ -3,14 +3,20 @@ from datetime import timedelta from django.conf import settings +from django.contrib.auth.models import User from django.utils import timezone -from firetower.auth.services import get_or_create_user_from_slack_id -from firetower.incidents.models import ExternalLinkType, Incident -from firetower.integrations.services import SlackService +from firetower.auth.models import ExternalProfile, ExternalProfileType +from firetower.auth.services import ( + get_or_create_user_from_email, + get_or_create_user_from_slack_id, +) +from firetower.incidents.models import ActionItem, ExternalLinkType, Incident +from firetower.integrations.services import LinearService, SlackService logger = logging.getLogger(__name__) _slack_service = SlackService() +_linear_service = LinearService() @dataclass @@ -117,3 +123,99 @@ def sync_incident_participants_from_slack( ) return stats + + +@dataclass +class ActionItemsSyncStats: + created: int = 0 + updated: int = 0 + deleted: int = 0 + errors: list[str] = field(default_factory=list) + skipped: bool = False + + +def _resolve_assignee_by_email(email: str) -> User | None: + user = get_or_create_user_from_email(email) + if not user: + return None + ExternalProfile.objects.get_or_create( + user=user, + type=ExternalProfileType.LINEAR, + defaults={"external_id": email}, + ) + return user + + +def sync_action_items_from_linear( + incident: Incident, force: bool = False +) -> ActionItemsSyncStats: + stats = ActionItemsSyncStats() + + if not force and incident.action_items_last_synced_at: + time_since_sync = timezone.now() - incident.action_items_last_synced_at + if time_since_sync < timedelta( + seconds=settings.ACTION_ITEM_SYNC_THROTTLE_SECONDS + ): + logger.info( + f"Skipping action item sync for incident {incident.id} - synced {time_since_sync.total_seconds():.0f}s ago" + ) + stats.skipped = True + return stats + + firetower_url = ( + f"{settings.FIRETOWER_BASE_URL}/{settings.PROJECT_KEY}-{incident.id}/" + ) + issues = _linear_service.get_issues_by_attachment_url(firetower_url) + + if issues is None: + error_msg = f"Failed to fetch Linear issues for incident {incident.id}" + logger.error(error_msg) + stats.errors.append(error_msg) + incident.action_items_last_synced_at = timezone.now() + incident.save(update_fields=["action_items_last_synced_at"]) + return stats + + logger.info(f"Syncing {len(issues)} Linear issues to incident {incident.id}") + + seen_linear_ids: set[str] = set() + + for issue in issues: + seen_linear_ids.add(issue["id"]) + + assignee = None + if issue.get("assignee_email"): + assignee = _resolve_assignee_by_email(issue["assignee_email"]) + + _, created = ActionItem.objects.update_or_create( + linear_issue_id=issue["id"], + defaults={ + "incident": incident, + "linear_identifier": issue["identifier"], + "title": issue["title"], + "status": issue["status"], + "assignee": assignee, + "url": issue["url"], + }, + ) + + if created: + stats.created += 1 + else: + stats.updated += 1 + + deleted_count, _ = ( + ActionItem.objects.filter(incident=incident) + .exclude(linear_issue_id__in=seen_linear_ids) + .delete() + ) + stats.deleted = deleted_count + + incident.action_items_last_synced_at = timezone.now() + incident.save(update_fields=["action_items_last_synced_at"]) + + logger.info( + f"Action item sync complete for incident {incident.id}: " + f"{stats.created} created, {stats.updated} updated, {stats.deleted} deleted" + ) + + return stats diff --git a/src/firetower/incidents/tests/test_action_items.py b/src/firetower/incidents/tests/test_action_items.py new file mode 100644 index 00000000..7de8516f --- /dev/null +++ b/src/firetower/incidents/tests/test_action_items.py @@ -0,0 +1,459 @@ +from datetime import timedelta +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User +from django.utils import timezone +from rest_framework.test import APIClient + +from firetower.auth.models import ExternalProfile, ExternalProfileType, UserProfile +from firetower.incidents.models import ( + ActionItem, + ActionItemStatus, + Incident, + IncidentSeverity, + IncidentStatus, +) +from firetower.incidents.services import ( + ActionItemsSyncStats, + sync_action_items_from_linear, +) +from firetower.integrations.services.linear import LinearService + + +def _make_linear_issue( + id="issue-1", + identifier="ENG-123", + title="Fix the bug", + url="https://linear.app/team/issue/ENG-123", + status="Todo", + assignee_email=None, +): + return { + "id": id, + "identifier": identifier, + "title": title, + "url": url, + "status": status, + "assignee_email": assignee_email, + } + + +@pytest.mark.django_db +class TestSyncActionItemsFromLinear: + def test_creates_action_items_from_linear(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + issues = [ + _make_linear_issue(id="id-1", identifier="ENG-1", title="Task 1"), + _make_linear_issue(id="id-2", identifier="ENG-2", title="Task 2"), + ] + + with patch( + "firetower.incidents.services._linear_service.get_issues_by_attachment_url" + ) as mock_fetch: + mock_fetch.return_value = issues + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.created == 2 + assert stats.updated == 0 + assert stats.deleted == 0 + assert stats.skipped is False + assert incident.action_items.count() == 2 + assert incident.action_items_last_synced_at is not None + + def test_updates_existing_action_items(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="Old title", + status=ActionItemStatus.TODO, + url="https://linear.app/team/issue/ENG-1", + ) + + issues = [ + _make_linear_issue( + id="id-1", + identifier="ENG-1", + title="New title", + status="In Progress", + ), + ] + + with patch( + "firetower.incidents.services._linear_service.get_issues_by_attachment_url" + ) as mock_fetch: + mock_fetch.return_value = issues + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.created == 0 + assert stats.updated == 1 + item = incident.action_items.get(linear_issue_id="id-1") + assert item.title == "New title" + assert item.status == "In Progress" + + def test_deletes_stale_action_items(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + ActionItem.objects.create( + incident=incident, + linear_issue_id="id-stale", + linear_identifier="ENG-99", + title="Stale item", + status=ActionItemStatus.TODO, + url="https://linear.app/team/issue/ENG-99", + ) + + issues = [ + _make_linear_issue(id="id-new", identifier="ENG-1", title="New item"), + ] + + with patch( + "firetower.incidents.services._linear_service.get_issues_by_attachment_url" + ) as mock_fetch: + mock_fetch.return_value = issues + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.created == 1 + assert stats.deleted == 1 + assert not ActionItem.objects.filter(linear_issue_id="id-stale").exists() + + def test_throttle_skips_recent_sync(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + action_items_last_synced_at=timezone.now() - timedelta(seconds=30), + ) + + stats = sync_action_items_from_linear(incident) + + assert stats.skipped is True + assert stats.created == 0 + + def test_force_bypasses_throttle(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + action_items_last_synced_at=timezone.now() - timedelta(seconds=30), + ) + + with patch( + "firetower.incidents.services._linear_service.get_issues_by_attachment_url" + ) as mock_fetch: + mock_fetch.return_value = [] + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.skipped is False + mock_fetch.assert_called_once() + + def test_handles_linear_api_failure(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + with patch( + "firetower.incidents.services._linear_service.get_issues_by_attachment_url" + ) as mock_fetch: + mock_fetch.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert len(stats.errors) == 1 + assert "Failed to fetch" in stats.errors[0] + + def test_resolves_assignee_by_email(self): + user = User.objects.create_user( + username="dev@example.com", + email="dev@example.com", + ) + + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + issues = [ + _make_linear_issue( + id="id-1", + identifier="ENG-1", + title="Task 1", + assignee_email="dev@example.com", + ), + ] + + with patch( + "firetower.incidents.services._linear_service.get_issues_by_attachment_url" + ) as mock_fetch: + mock_fetch.return_value = issues + + sync_action_items_from_linear(incident, force=True) + + item = incident.action_items.get(linear_issue_id="id-1") + assert item.assignee == user + assert ExternalProfile.objects.filter( + user=user, type=ExternalProfileType.LINEAR + ).exists() + + def test_creates_user_for_unknown_assignee_email(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + issues = [ + _make_linear_issue( + id="id-1", + identifier="ENG-1", + title="Task 1", + assignee_email="newdev@example.com", + ), + ] + + with patch( + "firetower.incidents.services._linear_service.get_issues_by_attachment_url" + ) as mock_fetch: + mock_fetch.return_value = issues + + sync_action_items_from_linear(incident, force=True) + + item = incident.action_items.get(linear_issue_id="id-1") + assert item.assignee is not None + assert item.assignee.email == "newdev@example.com" + assert ExternalProfile.objects.filter( + user=item.assignee, type=ExternalProfileType.LINEAR + ).exists() + + +@pytest.mark.django_db +class TestLinearService: + def test_graphql_returns_none_without_api_key(self): + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = {} + service = LinearService() + result = service._graphql("query { viewer { id } }") + assert result is None + + def test_get_issues_maps_state_types(self): + mock_response = { + "attachments": { + "nodes": [ + { + "issue": { + "id": "id-1", + "identifier": "ENG-1", + "title": "Started task", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "started"}, + "assignee": None, + } + }, + { + "issue": { + "id": "id-2", + "identifier": "ENG-2", + "title": "Done task", + "url": "https://linear.app/t/ENG-2", + "state": {"type": "completed"}, + "assignee": {"email": "dev@example.com"}, + } + }, + { + "issue": { + "id": "id-3", + "identifier": "ENG-3", + "title": "Cancelled task", + "url": "https://linear.app/t/ENG-3", + "state": {"type": "cancelled"}, + "assignee": None, + } + }, + ] + } + } + + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = {"API_KEY": "test-key"} + service = LinearService() + + with patch.object(service, "_graphql", return_value=mock_response): + issues = service.get_issues_by_attachment_url("INC-2000") + + assert issues is not None + assert len(issues) == 3 + assert issues[0]["status"] == "In Progress" + assert issues[1]["status"] == "Done" + assert issues[1]["assignee_email"] == "dev@example.com" + assert issues[2]["status"] == "Cancelled" + + def test_get_issues_deduplicates(self): + mock_response = { + "attachments": { + "nodes": [ + { + "issue": { + "id": "id-1", + "identifier": "ENG-1", + "title": "Task", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "unstarted"}, + "assignee": None, + } + }, + { + "issue": { + "id": "id-1", + "identifier": "ENG-1", + "title": "Task", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "unstarted"}, + "assignee": None, + } + }, + ] + } + } + + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = {"API_KEY": "test-key"} + service = LinearService() + + with patch.object(service, "_graphql", return_value=mock_response): + issues = service.get_issues_by_attachment_url("INC-2000") + assert len(issues) == 1 + + +@pytest.mark.django_db +class TestActionItemViews: + def setup_method(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="test@example.com", + email="test@example.com", + password="testpass123", + ) + self.client.force_authenticate(user=self.user) + + def test_list_action_items(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="Task 1", + status=ActionItemStatus.TODO, + 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 len(response.data) == 1 + assert response.data[0]["linear_identifier"] == "ENG-1" + assert response.data[0]["title"] == "Task 1" + + def test_list_action_items_includes_assignee_info(self): + user = User.objects.create_user( + username="dev@example.com", + email="dev@example.com", + first_name="Jane", + last_name="Dev", + ) + UserProfile.objects.filter(user=user).update( + avatar_url="https://example.com/avatar.jpg" + ) + + 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="Task 1", + status=ActionItemStatus.TODO, + assignee=user, + 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]["assignee_name"] == "Jane Dev" + assert ( + response.data[0]["assignee_avatar_url"] == "https://example.com/avatar.jpg" + ) + + def test_force_sync_action_items(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + with patch( + "firetower.incidents.views.sync_action_items_from_linear" + ) as mock_sync: + mock_sync.return_value = ActionItemsSyncStats(created=1) + + response = self.client.post( + f"/api/incidents/{incident.incident_number}/sync-action-items/" + ) + + assert response.status_code == 200 + assert response.data["success"] is True + assert response.data["stats"]["created"] == 1 + mock_sync.assert_called_once_with(incident, force=True) + + def test_action_items_respects_privacy(self): + other_user = User.objects.create_user( + username="other@example.com", + email="other@example.com", + ) + incident = Incident.objects.create( + title="Private Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + is_private=True, + captain=other_user, + ) + + response = self.client.get( + f"/api/ui/incidents/{incident.incident_number}/action-items/" + ) + + assert response.status_code == 404 diff --git a/src/firetower/incidents/urls.py b/src/firetower/incidents/urls.py index 42d5406e..3229639d 100644 --- a/src/firetower/incidents/urls.py +++ b/src/firetower/incidents/urls.py @@ -5,8 +5,10 @@ IncidentListCreateAPIView, IncidentRetrieveUpdateAPIView, TagListCreateAPIView, + action_item_list, incident_detail_ui, incident_list_ui, + sync_action_items, sync_incident_participants, ) @@ -19,6 +21,11 @@ incident_detail_ui, name="incident-detail-ui", ), + path( + "ui/incidents//action-items/", + action_item_list, + name="action-item-list", + ), # Service API endpoints path( "incidents/", @@ -30,6 +37,11 @@ IncidentRetrieveUpdateAPIView.as_view(), name="incident-retrieve-update", ), + path( + "incidents//sync-action-items/", + sync_action_items, + name="sync-action-items", + ), path( "incidents//sync-participants/", sync_incident_participants, diff --git a/src/firetower/incidents/views.py b/src/firetower/incidents/views.py index 8c0c2cb9..cfdfd430 100644 --- a/src/firetower/incidents/views.py +++ b/src/firetower/incidents/views.py @@ -40,6 +40,7 @@ get_year_periods, ) from .serializers import ( + ActionItemSerializer, IncidentListUISerializer, IncidentOrRedirectReadSerializer, IncidentReadSerializer, @@ -47,12 +48,30 @@ TagCreateSerializer, TagSerializer, ) -from .services import ParticipantsSyncStats, sync_incident_participants_from_slack +from .services import ( + ActionItemsSyncStats, + ParticipantsSyncStats, + sync_action_items_from_linear, + sync_incident_participants_from_slack, +) from .utils import sort_tags_with_overrides logger = logging.getLogger(__name__) +def parse_incident_id(incident_id: str) -> int: + project_key = settings.PROJECT_KEY + incident_pattern = rf"^{re.escape(project_key)}-(\d+)$" + match = re.match(incident_pattern, incident_id, re.IGNORECASE) + + if not match: + raise ValidationError( + f"Invalid incident ID format. Expected format: {project_key}- (e.g., {project_key}-123)" + ) + + return int(match.group(1)) + + class IncidentListUIView(generics.ListAPIView): """ List all incidents from database. @@ -118,21 +137,11 @@ def get_object(self) -> IncidentOrRedirect: Returns incident if found and user has access, otherwise 404. """ incident_id = self.kwargs["incident_id"] - project_key = settings.PROJECT_KEY - - # Extract numeric ID from incident number (INC-2000 -> 2000), case-insensitive - incident_pattern = rf"^{re.escape(project_key)}-(\d+)$" - match = re.match(incident_pattern, incident_id, re.IGNORECASE) + numeric_id = parse_incident_id(incident_id) - if not match: - raise ValidationError( - f"Invalid incident ID format. Expected format: {project_key}- (e.g., {project_key}-123)" - ) - - numeric_id = int(match.group(1)) if numeric_id < INCIDENT_ID_START: return IncidentOrRedirect( - redirect=f"{settings.JIRA['DOMAIN']}/browse/{project_key}-{numeric_id}" + redirect=f"{settings.JIRA['DOMAIN']}/browse/{settings.PROJECT_KEY}-{numeric_id}" ) # Get incident by numeric ID @@ -231,19 +240,7 @@ def get_object(self) -> Incident: Filters by visibility before lookup to avoid leaking incident existence. """ incident_id = self.kwargs["incident_id"] - project_key = settings.PROJECT_KEY - - # Extract numeric ID from incident number (INC-2000 -> 2000), case-insensitive - incident_pattern = rf"^{re.escape(project_key)}-(\d+)$" - match = re.match(incident_pattern, incident_id, re.IGNORECASE) - - if not match: - raise ValidationError( - f"Invalid incident ID format. Expected format: {project_key}- (e.g., {project_key}-123)" - ) - - # Get incident by numeric ID, filtered by visibility - numeric_id = int(match.group(1)) + numeric_id = parse_incident_id(incident_id) queryset = self.get_queryset() # Filter by visibility before lookup (404 if not visible) @@ -291,24 +288,10 @@ def get_object(self) -> Incident: Returns incident if found and user has access, otherwise 404. """ incident_id = self.kwargs["incident_id"] - project_key = settings.PROJECT_KEY - - # Case-insensitive match for incident ID format - incident_pattern = rf"^{re.escape(project_key)}-(\d+)$" - match = re.match(incident_pattern, incident_id, re.IGNORECASE) - - if not match: - raise ValidationError( - f"Invalid incident ID format. Expected format: {project_key}- (e.g., {project_key}-123)" - ) - - numeric_id = int(match.group(1)) - queryset = self.get_queryset() - queryset = filter_visible_to_user(queryset, self.request.user) + numeric_id = parse_incident_id(incident_id) + queryset = filter_visible_to_user(self.get_queryset(), self.request.user) obj = get_object_or_404(queryset, id=numeric_id) - - # Check object permissions self.check_object_permissions(self.request, obj) return obj @@ -341,6 +324,76 @@ def post(self, request: Request, incident_id: str) -> Response: sync_incident_participants = SyncIncidentParticipantsView.as_view() +class ActionItemListView(generics.ListAPIView): + permission_classes = [IncidentPermission] + serializer_class = ActionItemSerializer + pagination_class = None + + def get_queryset(self) -> QuerySet: + return self._get_incident().action_items.select_related("assignee__userprofile") + + def _get_incident(self) -> Incident: + if not hasattr(self, "_incident"): + numeric_id = parse_incident_id(self.kwargs["incident_id"]) + queryset = filter_visible_to_user(Incident.objects.all(), self.request.user) + self._incident = get_object_or_404(queryset, id=numeric_id) + return self._incident + + def list(self, request: Request, *args: object, **kwargs: object) -> Response: + incident = self._get_incident() + + try: + sync_action_items_from_linear(incident) + except Exception as e: + logger.error( + f"Failed to sync action items for incident {incident.id}: {e}", + exc_info=True, + ) + + return super().list(request, *args, **kwargs) + + +class SyncActionItemsView(generics.GenericAPIView): + permission_classes = [IncidentPermission] + + def get_queryset(self) -> QuerySet[Incident]: + return Incident.objects.all() + + def _get_incident(self) -> Incident: + numeric_id = parse_incident_id(self.kwargs["incident_id"]) + queryset = filter_visible_to_user(self.get_queryset(), self.request.user) + obj = get_object_or_404(queryset, id=numeric_id) + self.check_object_permissions(self.request, obj) + return obj + + def post(self, request: Request, incident_id: str) -> Response: + incident = self._get_incident() + + try: + stats = sync_action_items_from_linear(incident, force=True) + return Response({"success": True, "stats": asdict(stats)}) + except Exception as e: + logger.error( + f"Failed to force sync action items for incident {incident.id}: {e}", + exc_info=True, + ) + error_stats = ActionItemsSyncStats( + errors=["Failed to sync action items from Linear"] + ) + return Response( + { + "success": False, + "error": "Failed to sync action items from Linear", + "stats": asdict(error_stats), + }, + status=500, + ) + + +action_item_list = ActionItemListView.as_view() +sync_action_items = SyncActionItemsView.as_view() + + class TagListCreateAPIView(generics.ListCreateAPIView): """ List or create tags. diff --git a/src/firetower/integrations/services/__init__.py b/src/firetower/integrations/services/__init__.py index 568cfcb7..40946c24 100644 --- a/src/firetower/integrations/services/__init__.py +++ b/src/firetower/integrations/services/__init__.py @@ -1,6 +1,7 @@ """Services package for external integrations.""" from .jira import JiraService +from .linear import LinearService from .slack import SlackService -__all__ = ["JiraService", "SlackService"] +__all__ = ["JiraService", "LinearService", "SlackService"] diff --git a/src/firetower/integrations/services/linear.py b/src/firetower/integrations/services/linear.py new file mode 100644 index 00000000..504a26a8 --- /dev/null +++ b/src/firetower/integrations/services/linear.py @@ -0,0 +1,143 @@ +import logging +from typing import Any + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +LINEAR_API_URL = "https://api.linear.app/graphql" + +LINEAR_STATE_TYPE_MAP = { + "triage": "Todo", + "backlog": "Todo", + "unstarted": "Todo", + "started": "In Progress", + "completed": "Done", + "cancelled": "Cancelled", +} + + +class LinearService: + def __init__(self) -> None: + linear_config = settings.LINEAR + self.api_key = linear_config.get("API_KEY") + + if not self.api_key: + logger.warning("Linear API key not configured") + + def _graphql(self, query: str, variables: dict | None = None) -> dict | None: + if not self.api_key: + logger.warning("Cannot make Linear API call - API key not configured") + return None + + try: + response = requests.post( + LINEAR_API_URL, + json={"query": query, "variables": variables or {}}, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + if "errors" in data: + logger.error( + "Linear GraphQL errors", + extra={"errors": data["errors"]}, + ) + return None + + return data.get("data") + except requests.RequestException as e: + logger.error(f"Linear API request failed: {e}") + return None + + def get_issues_by_attachment_url( + self, url_contains: str + ) -> list[dict[str, Any]] | None: + query = """ + query($url_contains: String!, $after: String) { + attachments( + first: 50, + after: $after, + filter: { url: { contains: $url_contains } } + ) { + nodes { + issue { + id + identifier + title + url + state { + type + } + assignee { + email + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + + issues: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + cursor: str | None = None + max_pages = 25 + page = 0 + + while page < max_pages: + page += 1 + variables: dict[str, Any] = {"url_contains": url_contains} + if cursor is not None: + variables["after"] = cursor + + data = self._graphql(query, variables) + if data is None: + return None + + attachments = data.get("attachments", {}) + + for node in attachments.get("nodes", []): + issue = node.get("issue") + if not issue or issue["id"] in seen_ids: + continue + seen_ids.add(issue["id"]) + + state_type = issue.get("state", {}).get("type", "") + status = LINEAR_STATE_TYPE_MAP.get(state_type, "Todo") + + assignee_email = None + if issue.get("assignee"): + assignee_email = issue["assignee"].get("email") + + issues.append( + { + "id": issue["id"], + "identifier": issue["identifier"], + "title": issue["title"], + "url": issue["url"], + "status": status, + "assignee_email": assignee_email, + } + ) + + page_info = attachments.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + cursor = page_info.get("endCursor") + if cursor is None: + break + + logger.info( + f"Found {len(issues)} Linear issues for URL containing '{url_contains}'" + ) + return issues diff --git a/src/firetower/settings.py b/src/firetower/settings.py index e69af035..e63bc98b 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -59,6 +59,7 @@ def cmd_needs_dummy_config() -> bool: # Global project settings PROJECT_KEY = config.project_key +FIRETOWER_BASE_URL = config.firetower_base_url PINNED_REGIONS = config.pinned_regions # Quick-start development settings - unsuitable for production @@ -228,9 +229,21 @@ class SlackSettings(TypedDict): PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds) -FIRETOWER_BASE_URL = config.firetower_base_url HOOKS_ENABLED = config.hooks_enabled +# Linear Integration Configuration +LINEAR = ( + { + "API_KEY": config.linear.api_key, + } + if config.linear + else {} +) + +ACTION_ITEM_SYNC_THROTTLE_SECONDS = ( + int(config.linear.action_item_sync_throttle_seconds) if config.linear else 300 +) + # Django REST Framework Configuration REST_FRAMEWORK = { # Pagination