diff --git a/config.ci.toml b/config.ci.toml index d4e557dc..3ef3f00d 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -1,5 +1,6 @@ project_key = "INC" django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69" +salt_key = "ci-test-salt-key" sentry_dsn = "" firetower_base_url = "http://localhost:5173" region_grouping = [] @@ -20,6 +21,13 @@ incident_feed_channel_id = "" always_invited_ids = [] incident_guide_message = "" +[linear] +client_id = "ci-test-client-id" +client_secret = "ci-test-client-secret" +action_item_sync_throttle_seconds = 300 +team_id = "ci-test-team-id" +project_id = "" + [auth] iap_enabled = false iap_audience = "" diff --git a/config.example.toml b/config.example.toml index 2a2ea3bd..99dcd08e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,6 +1,8 @@ project_key = "INC" log_level = "INFO" django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69" +# Salt for django-fernet-encrypted-fields. In prod, use a unique value: python -c "import secrets; print(secrets.token_urlsafe(32))" +salt_key = "" sentry_dsn = "https://your-sentry-dsn@o1.ingest.us.sentry.io/project-id" firetower_base_url = "http://localhost:5173" region_grouping = [["region-a", "region-b"], ["region-c", "region-d"]] @@ -35,6 +37,15 @@ integration_key = "" id = "" integration_key = "" +[linear] +client_id = "" +client_secret = "" +action_item_sync_throttle_seconds = 300 +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 + [notion] integration_token = "" database_id = "" diff --git a/pyproject.toml b/pyproject.toml index 3acf843d..e39cad99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "ddtrace==3.18.1", "django>=5.2.14,<6", "django-cors-headers>=4.9.0", + "django-fernet-encrypted-fields>=0.3.1", "django-kubernetes>=1.1.0", "djangorestframework>=3.15.2", "google-auth>=2.37.0", diff --git a/src/firetower/config.py b/src/firetower/config.py index a9ae7392..f59a6790 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -64,6 +64,16 @@ class StatuspageConfig: url: str +@deserialize +class LinearConfig: + client_id: str + client_secret: str + action_item_sync_throttle_seconds: int + team_id: str = "" + project_id: str = "" + sync_identifiers: bool = False + + @deserialize class AuthConfig: iap_enabled: bool @@ -78,14 +88,16 @@ class ConfigFile: postgres: PostgresConfig slack: SlackConfig + linear: LinearConfig | None auth: AuthConfig pagerduty: PagerDutyConfig | None statuspage: StatuspageConfig | None project_key: str + firetower_base_url: str django_secret_key: str sentry_dsn: str - firetower_base_url: str + salt_key: str notion: NotionConfig | None = None genai: GenAIConfig | None = None log_level: str = "INFO" @@ -148,14 +160,16 @@ def __init__(self) -> None: iap_enabled=False, iap_audience="", ) + self.linear = None self.notion = None self.genai = None self.pagerduty = None self.statuspage = None self.project_key = "" + self.firetower_base_url = "" self.django_secret_key = "" + self.salt_key = "" self.sentry_dsn = "" self.region_grouping: list[list[str]] = [] - self.firetower_base_url = "" self.log_level = "INFO" self.hooks_enabled = False diff --git a/src/firetower/incidents/admin.py b/src/firetower/incidents/admin.py index 50b03d09..f1cca461 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,39 @@ 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") + + if message_parts: + self.message_user(request, f"Action item sync: {', '.join(message_parts)}") + else: + self.message_user(request, "No incidents selected.") + @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 636f7e3d..50b1f9f2 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -1,5 +1,6 @@ import logging from dataclasses import dataclass +from typing import Any from django.conf import settings from django.contrib.auth.models import User @@ -15,6 +16,7 @@ ) from firetower.integrations.services import ( DatadogService, + LinearService, PagerDutyService, SlackService, ) @@ -856,6 +858,128 @@ def decorate_incident_channel( logger.exception(f"Failed to post to feed channel for {ctx.channel_name}") +def _linear_issue_title(incident: Incident) -> str: + if incident.is_private: + return f"[{incident.incident_number}] Private Incident" + return f"[{incident.incident_number}] {incident.title}" + + +def _sync_linear_title(incident: Incident) -> None: + if not incident.linear_parent_issue_id: + return + try: + linear_service = LinearService() + linear_service.update_issue( + incident.linear_parent_issue_id, + title=_linear_issue_title(incident), + ) + except Exception: + logger.exception( + f"Failed to update Linear issue title for incident {incident.id}" + ) + + +LINEAR_PARENT_DESCRIPTION = ( + "Relate action items to this ticket to have them tracked by Firetower. " + "Child issues or other relations (related, blocking, etc.) will all work. " + "Do not update title or captain here, use Firetower for that." +) + + +MAX_CLAIM_ATTEMPTS = 5 + + +def _claim_linear_issue( + linear_service: LinearService, + incident: Incident, + team_id: str, + project_id: str | None, +) -> dict[str, Any] | None: + identifier = incident.incident_number + + for _ in range(MAX_CLAIM_ATTEMPTS): + issue = linear_service.get_issue(identifier) + if issue: + return issue + linear_service.create_issue("Placeholder", "", team_id, project_id) + + return None + + +def _create_linear_parent_issue(incident: Incident) -> None: + team_id = str(settings.LINEAR.get("TEAM_ID", "")) + if not team_id: + return + + linear_link, created = ExternalLink.objects.get_or_create( + incident=incident, + type=ExternalLinkType.LINEAR, + defaults={"url": ""}, + ) + if not created: + logger.info(f"Incident {incident.id} already has a Linear link, skipping") + return + + try: + linear_service = LinearService() + project_id = str(settings.LINEAR.get("PROJECT_ID", "")) or None + sync_identifiers = settings.LINEAR.get("SYNC_IDENTIFIERS", False) + title = _linear_issue_title(incident) + + if sync_identifiers: + issue = _claim_linear_issue(linear_service, incident, team_id, project_id) + if not issue: + linear_link.delete() + logger.warning( + f"Failed to claim Linear issue for incident {incident.id}" + ) + return + + states = linear_service.get_workflow_states(team_id) + started_state_id = states.get("started") if states else None + if not linear_service.update_issue( + issue["id"], + title=title, + description=LINEAR_PARENT_DESCRIPTION, + state_id=started_state_id, + ): + logger.warning( + f"Failed to update claimed Linear issue for incident {incident.id}" + ) + else: + issue = linear_service.create_issue( + title, LINEAR_PARENT_DESCRIPTION, team_id, project_id + ) + if not issue: + linear_link.delete() + logger.warning( + f"Failed to create Linear issue for incident {incident.id}" + ) + return + + linear_link.url = issue["url"] + linear_link.save(update_fields=["url"]) + + incident.linear_parent_issue_id = issue["id"] + incident.save(update_fields=["linear_parent_issue_id"]) + except Exception: + linear_link.delete() + logger.exception( + f"Failed to create Linear parent issue for incident {incident.id}" + ) + return + + try: + incident_url = _build_incident_url(incident) + linear_service.create_attachment( + issue["id"], incident_url, f"Firetower: {incident.incident_number}" + ) + except Exception: + logger.exception( + f"Failed to create Linear attachment for incident {incident.id}" + ) + + def on_incident_created(incident: Incident) -> None: # Use get_or_create to atomically claim the ExternalLink row before calling # the Slack API. If two concurrent requests both reach this point, only one @@ -962,6 +1086,13 @@ def on_incident_created(incident: Incident) -> None: _create_datadog_notebook(incident, channel_id) _create_troubleshooting_doc(incident, channel_id) + try: + _create_linear_parent_issue(incident) + except Exception: + logger.exception( + f"Failed to create Linear parent issue for incident {incident.id}" + ) + def on_status_changed(incident: Incident, old_status: str) -> None: channel_id: str | None = None @@ -1045,31 +1176,31 @@ def on_severity_changed(incident: Incident, old_severity: str) -> None: def on_title_changed(incident: Incident) -> None: try: channel_id = _get_channel_id(incident) - if not channel_id: - return - - _slack_service.set_channel_topic(channel_id, build_channel_topic(incident)) + if channel_id: + _slack_service.set_channel_topic(channel_id, build_channel_topic(incident)) except Exception: logger.exception(f"Error in on_title_changed for incident {incident.id}") + _sync_linear_title(incident) + def on_visibility_changed(incident: Incident) -> None: try: channel_id = _get_channel_id(incident) - if not channel_id: - return - - visibility = "private" if incident.is_private else "public" - incident_url = _build_incident_url(incident) - message = ( - f"This incident has been marked as *{visibility}* in Firetower. " - f"If you want to make this channel {visibility}, you will need a Slack admin to make the change.\n" - f"<{incident_url}|View in Firetower>" - ) - _slack_service.post_message(channel_id, message) + if channel_id: + visibility = "private" if incident.is_private else "public" + incident_url = _build_incident_url(incident) + message = ( + f"This incident has been marked as *{visibility}* in Firetower. " + f"If you want to make this channel {visibility}, you will need a Slack admin to make the change.\n" + f"<{incident_url}|View in Firetower>" + ) + _slack_service.post_message(channel_id, message) except Exception: logger.exception(f"Error in on_visibility_changed for incident {incident.id}") + _sync_linear_title(incident) + def on_captain_changed(incident: Incident) -> None: try: diff --git a/src/firetower/incidents/migrations/0016_add_action_item_model.py b/src/firetower/incidents/migrations/0016_add_action_item_model.py new file mode 100644 index 00000000..9cc0b865 --- /dev/null +++ b/src/firetower/incidents/migrations/0016_add_action_item_model.py @@ -0,0 +1,94 @@ +# 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", "0015_add_notion_troubleshooting_link_type"), + 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.AddField( + model_name="incident", + name="linear_parent_issue_id", + field=models.CharField(blank=True, max_length=255, 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)), + ("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, + ), + ), + ( + "relation_type", + models.CharField( + choices=[ + ("child", "Child"), + ("related", "Related"), + ("blocked_by", "Blocked By"), + ("blocks", "Blocks"), + ("duplicate", "Duplicate"), + ], + default="child", + max_length=20, + ), + ), + ("url", models.URLField(max_length=500)), + ("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"], + "unique_together": {("incident", "linear_issue_id")}, + }, + ), + ] diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index 76b9e04e..1675acc5 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -185,6 +185,8 @@ 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) + linear_parent_issue_id = models.CharField(max_length=255, null=True, blank=True) # Milestone timestamps (for postmortem) total_downtime = models.IntegerField( @@ -327,6 +329,55 @@ 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 ActionItemRelationType(models.TextChoices): + CHILD = "child", "Child" + RELATED = "related", "Related" + BLOCKED_BY = "blocked_by", "Blocked By" + BLOCKS = "blocks", "Blocks" + DUPLICATE = "duplicate", "Duplicate" + + +class ActionItem(models.Model): + incident = models.ForeignKey( + "Incident", on_delete=models.CASCADE, related_name="action_items" + ) + linear_issue_id = models.CharField(max_length=255) + 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 + ) + relation_type = models.CharField( + max_length=20, + choices=ActionItemRelationType.choices, + default=ActionItemRelationType.CHILD, + ) + assignee = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="action_items", + ) + url = models.URLField(max_length=500) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["created_at"] + unique_together = [("incident", "linear_issue_id")] + + 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 7a5c6cb3..69e21e58 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, @@ -654,6 +655,33 @@ 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", + "relation_type", + "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..3e7de46d 100644 --- a/src/firetower/incidents/services.py +++ b/src/firetower/incidents/services.py @@ -3,14 +3,32 @@ 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, + ActionItemStatus, + ExternalLinkType, + Incident, +) +from firetower.integrations.services import LinearService, SlackService logger = logging.getLogger(__name__) _slack_service = SlackService() +_linear_service: LinearService | None = None + + +def _get_linear_service() -> LinearService: + global _linear_service # noqa: PLW0603 + if _linear_service is None: + _linear_service = LinearService() + return _linear_service @dataclass @@ -117,3 +135,174 @@ 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_assignees( + issues: dict[str, dict], +) -> dict[str, User | None]: + email_to_linear_id: dict[str, str | None] = {} + for issue in issues.values(): + email = issue.get("assignee_email") + if email: + email_to_linear_id[email] = issue.get("assignee_linear_id") + + if not email_to_linear_id: + return {} + + existing_users = { + u.email: u for u in User.objects.filter(email__in=email_to_linear_id.keys()) + } + + resolved: dict[str, User | None] = {} + for email, linear_id in email_to_linear_id.items(): + user = existing_users.get(email) + if not user: + user = get_or_create_user_from_email(email) + if not user: + resolved[email] = None + continue + resolved[email] = user + if linear_id: + ExternalProfile.objects.update_or_create( + user=user, + type=ExternalProfileType.LINEAR, + defaults={"external_id": linear_id}, + ) + + return resolved + + +COMPLETED_STATUSES = {ActionItemStatus.DONE, ActionItemStatus.CANCELLED} + + +def _update_parent_issue_status( + incident: Incident, linear_service: LinearService +) -> None: + team_id = str(settings.LINEAR.get("TEAM_ID", "")) + if not team_id or not incident.linear_parent_issue_id: + return + + statuses = list(incident.action_items.values_list("status", flat=True)) + if not statuses: + return + + all_complete = all(s in COMPLETED_STATUSES for s in statuses) + + states = linear_service.get_workflow_states(team_id) + if not states: + return + + target_state = "completed" if all_complete else "started" + state_id = states.get(target_state) + if state_id: + linear_service.update_issue(incident.linear_parent_issue_id, state_id=state_id) + + +def sync_action_items_from_linear( + incident: Incident, force: bool = False +) -> ActionItemsSyncStats: + stats = ActionItemsSyncStats() + + if not incident.linear_parent_issue_id: + stats.skipped = True + return stats + + 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 + + linear_service = _get_linear_service() + parent_id = incident.linear_parent_issue_id + + children = linear_service.get_child_issues(parent_id) + if children is None: + error_msg = f"Failed to fetch child 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 + + related = linear_service.get_related_issues(parent_id) + if related is None: + error_msg = f"Failed to fetch related 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 + + all_issues: dict[str, dict] = {} + for issue in children: + all_issues[issue["id"]] = issue + for issue in related: + if issue["id"] not in all_issues: + all_issues[issue["id"]] = issue + + logger.info(f"Syncing {len(all_issues)} Linear issues to incident {incident.id}") + + assignee_map = _resolve_assignees(all_issues) + seen_linear_ids: set[str] = set() + + for issue in all_issues.values(): + seen_linear_ids.add(issue["id"]) + + assignee = assignee_map.get(issue.get("assignee_email", "")) + + _, created = ActionItem.objects.update_or_create( + incident=incident, + linear_issue_id=issue["id"], + defaults={ + "linear_identifier": issue["identifier"], + "title": issue["title"], + "status": issue["status"], + "relation_type": issue["relation_type"], + "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"]) + + try: + _update_parent_issue_status(incident, linear_service) + except Exception: + logger.exception( + f"Failed to update Linear parent issue status for incident {incident.id}" + ) + + 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..fe274dde --- /dev/null +++ b/src/firetower/incidents/tests/test_action_items.py @@ -0,0 +1,1230 @@ +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.hooks import ( + _create_linear_parent_issue, + on_title_changed, + on_visibility_changed, +) +from firetower.incidents.models import ( + ActionItem, + ActionItemRelationType, + ActionItemStatus, + ExternalLink, + ExternalLinkType, + 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", + relation_type="child", + assignee_email=None, + assignee_linear_id=None, +): + return { + "id": id, + "identifier": identifier, + "title": title, + "url": url, + "status": status, + "relation_type": relation_type, + "assignee_email": assignee_email, + "assignee_linear_id": assignee_linear_id, + } + + +@pytest.mark.django_db +class TestSyncActionItemsFromLinear: + def _make_incident(self, **kwargs): + defaults = { + "title": "Test Incident", + "status": IncidentStatus.ACTIVE, + "severity": IncidentSeverity.P1, + "linear_parent_issue_id": "parent-issue-id", + } + defaults.update(kwargs) + return Incident.objects.create(**defaults) + + def test_skips_when_no_parent_issue(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.skipped is True + assert stats.created == 0 + + def test_creates_action_items_from_children(self): + incident = self._make_incident() + + children = [ + _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._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = children + mock_get.return_value.get_related_issues.return_value = [] + mock_get.return_value.get_workflow_states.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.created == 2 + assert stats.updated == 0 + assert stats.deleted == 0 + assert incident.action_items.count() == 2 + assert incident.action_items_last_synced_at is not None + + def test_creates_action_items_from_relations(self): + incident = self._make_incident() + + related = [ + _make_linear_issue( + id="id-r1", + identifier="ENG-10", + title="Related task", + relation_type="related", + ), + _make_linear_issue( + id="id-r2", + identifier="ENG-11", + title="Blocking task", + relation_type="blocks", + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = [] + mock_get.return_value.get_related_issues.return_value = related + mock_get.return_value.get_workflow_states.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.created == 2 + item_r1 = incident.action_items.get(linear_issue_id="id-r1") + assert item_r1.relation_type == ActionItemRelationType.RELATED + item_r2 = incident.action_items.get(linear_issue_id="id-r2") + assert item_r2.relation_type == ActionItemRelationType.BLOCKS + + def test_combines_children_and_relations(self): + incident = self._make_incident() + + children = [ + _make_linear_issue(id="id-1", identifier="ENG-1", title="Child task"), + ] + related = [ + _make_linear_issue( + id="id-2", + identifier="ENG-2", + title="Related task", + relation_type="related", + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = children + mock_get.return_value.get_related_issues.return_value = related + mock_get.return_value.get_workflow_states.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.created == 2 + assert incident.action_items.count() == 2 + + def test_same_issue_on_multiple_incidents(self): + incident_a = self._make_incident(linear_parent_issue_id="parent-a") + incident_b = self._make_incident(linear_parent_issue_id="parent-b") + + shared_issue = [ + _make_linear_issue(id="shared-1", identifier="ENG-99", title="Shared task"), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = shared_issue + mock_get.return_value.get_related_issues.return_value = [] + mock_get.return_value.get_workflow_states.return_value = None + + sync_action_items_from_linear(incident_a, force=True) + sync_action_items_from_linear(incident_b, force=True) + + assert incident_a.action_items.filter(linear_issue_id="shared-1").exists() + assert incident_b.action_items.filter(linear_issue_id="shared-1").exists() + assert ActionItem.objects.filter(linear_issue_id="shared-1").count() == 2 + + def test_deduplicates_children_and_relations(self): + incident = self._make_incident() + + children = [ + _make_linear_issue(id="id-1", identifier="ENG-1", title="Task"), + ] + related = [ + _make_linear_issue( + id="id-1", + identifier="ENG-1", + title="Task", + relation_type="related", + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = children + mock_get.return_value.get_related_issues.return_value = related + mock_get.return_value.get_workflow_states.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.created == 1 + item = incident.action_items.get(linear_issue_id="id-1") + assert item.relation_type == ActionItemRelationType.CHILD + + def test_updates_existing_action_items(self): + incident = self._make_incident() + 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", + ) + + children = [ + _make_linear_issue( + id="id-1", + identifier="ENG-1", + title="New title", + status="In Progress", + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = children + mock_get.return_value.get_related_issues.return_value = [] + mock_get.return_value.get_workflow_states.return_value = None + + 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 = self._make_incident() + 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", + ) + + children = [ + _make_linear_issue(id="id-new", identifier="ENG-1", title="New item"), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = children + mock_get.return_value.get_related_issues.return_value = [] + mock_get.return_value.get_workflow_states.return_value = None + + 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 = self._make_incident( + 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 = self._make_incident( + action_items_last_synced_at=timezone.now() - timedelta(seconds=30), + ) + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = [] + mock_get.return_value.get_related_issues.return_value = [] + mock_get.return_value.get_workflow_states.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert stats.skipped is False + mock_get.return_value.get_child_issues.assert_called_once() + + def test_handles_children_api_failure(self): + incident = self._make_incident() + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert len(stats.errors) == 1 + assert "child issues" in stats.errors[0] + incident.refresh_from_db() + assert incident.action_items_last_synced_at is not None + + def test_handles_relations_api_failure(self): + incident = self._make_incident() + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = [] + mock_get.return_value.get_related_issues.return_value = None + + stats = sync_action_items_from_linear(incident, force=True) + + assert len(stats.errors) == 1 + assert "related issues" in stats.errors[0] + incident.refresh_from_db() + assert incident.action_items_last_synced_at is not None + + def test_resolves_assignee_by_email(self): + user = User.objects.create_user( + username="dev@example.com", + email="dev@example.com", + ) + + incident = self._make_incident() + + children = [ + _make_linear_issue( + id="id-1", + identifier="ENG-1", + title="Task 1", + assignee_email="dev@example.com", + assignee_linear_id="linear-user-123", + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = children + mock_get.return_value.get_related_issues.return_value = [] + mock_get.return_value.get_workflow_states.return_value = None + + sync_action_items_from_linear(incident, force=True) + + item = incident.action_items.get(linear_issue_id="id-1") + assert item.assignee == user + profile = ExternalProfile.objects.get( + user=user, type=ExternalProfileType.LINEAR + ) + assert profile.external_id == "linear-user-123" + + def test_creates_user_for_unknown_assignee_email(self): + incident = self._make_incident() + + children = [ + _make_linear_issue( + id="id-1", + identifier="ENG-1", + title="Task 1", + assignee_email="newdev@example.com", + assignee_linear_id="linear-user-456", + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_get.return_value.get_child_issues.return_value = children + mock_get.return_value.get_related_issues.return_value = [] + mock_get.return_value.get_workflow_states.return_value = None + + 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" + profile = ExternalProfile.objects.get( + user=item.assignee, type=ExternalProfileType.LINEAR + ) + assert profile.external_id == "linear-user-456" + + def test_auto_completes_parent_when_all_done(self, settings): + settings.LINEAR = {"TEAM_ID": "team-1"} + incident = self._make_incident() + + children = [ + _make_linear_issue( + id="id-1", identifier="ENG-1", title="T1", status="Done" + ), + _make_linear_issue( + id="id-2", identifier="ENG-2", title="T2", status="Cancelled" + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_service = mock_get.return_value + mock_service.get_child_issues.return_value = children + mock_service.get_related_issues.return_value = [] + mock_service.get_workflow_states.return_value = { + "completed": "state-done", + "backlog": "state-backlog", + } + mock_service.update_issue.return_value = True + + sync_action_items_from_linear(incident, force=True) + + mock_service.update_issue.assert_called_once_with( + "parent-issue-id", state_id="state-done" + ) + + def test_sets_parent_to_started_when_incomplete_items(self, settings): + settings.LINEAR = {"TEAM_ID": "team-1"} + incident = self._make_incident() + + children = [ + _make_linear_issue( + id="id-1", identifier="ENG-1", title="T1", status="Done" + ), + _make_linear_issue( + id="id-2", identifier="ENG-2", title="T2", status="Todo" + ), + ] + + with patch("firetower.incidents.services._get_linear_service") as mock_get: + mock_service = mock_get.return_value + mock_service.get_child_issues.return_value = children + mock_service.get_related_issues.return_value = [] + mock_service.get_workflow_states.return_value = { + "completed": "state-done", + "started": "state-started", + } + mock_service.update_issue.return_value = True + + sync_action_items_from_linear(incident, force=True) + + mock_service.update_issue.assert_called_once_with( + "parent-issue-id", state_id="state-started" + ) + + +@pytest.mark.django_db +class TestLinearService: + def test_graphql_returns_none_without_credentials(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_create_issue(self): + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + mock_response = { + "issueCreate": { + "success": True, + "issue": { + "id": "issue-uuid", + "identifier": "ENG-100", + "url": "https://linear.app/t/ENG-100", + }, + } + } + + with patch.object(service, "_graphql", return_value=mock_response): + result = service.create_issue("Title", "Desc", "team-1") + + assert result is not None + assert result["id"] == "issue-uuid" + assert result["identifier"] == "ENG-100" + + def test_create_issue_with_project(self): + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + with patch.object( + service, + "_graphql", + return_value={ + "issueCreate": { + "success": True, + "issue": {"id": "id", "identifier": "E-1", "url": "url"}, + } + }, + ) as mock_gql: + service.create_issue("Title", "Desc", "team-1", "project-1") + + call_args = mock_gql.call_args + input_data = call_args[0][1]["input"] + assert input_data["projectId"] == "project-1" + + def test_create_issue_failure(self): + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + with patch.object( + service, "_graphql", return_value={"issueCreate": {"success": False}} + ): + result = service.create_issue("Title", "Desc", "team-1") + assert result is None + + def test_get_child_issues_maps_state_types(self): + mock_response = { + "issue": { + "children": { + "nodes": [ + { + "id": "id-1", + "identifier": "ENG-1", + "title": "Started task", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "started"}, + "assignee": None, + }, + { + "id": "id-2", + "identifier": "ENG-2", + "title": "Done task", + "url": "https://linear.app/t/ENG-2", + "state": {"type": "completed"}, + "assignee": {"id": "user-1", "email": "dev@example.com"}, + }, + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + with patch.object(service, "_graphql", return_value=mock_response): + issues = service.get_child_issues("parent-id") + + assert issues is not None + assert len(issues) == 2 + assert issues[0]["status"] == "In Progress" + assert issues[0]["relation_type"] == "child" + assert issues[1]["status"] == "Done" + assert issues[1]["assignee_email"] == "dev@example.com" + + def test_get_related_issues_maps_relation_types(self): + forward_response = { + "issue": { + "relations": { + "nodes": [ + { + "type": "related", + "relatedIssue": { + "id": "id-1", + "identifier": "ENG-1", + "title": "Related task", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "unstarted"}, + "assignee": None, + }, + }, + { + "type": "blocks", + "relatedIssue": { + "id": "id-2", + "identifier": "ENG-2", + "title": "Blocking task", + "url": "https://linear.app/t/ENG-2", + "state": {"type": "started"}, + "assignee": None, + }, + }, + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + inverse_response = { + "issue": { + "inverseRelations": { + "nodes": [], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + with patch.object( + service, "_graphql", side_effect=[forward_response, inverse_response] + ): + issues = service.get_related_issues("parent-id") + + assert issues is not None + assert len(issues) == 2 + assert issues[0]["relation_type"] == "related" + assert issues[1]["relation_type"] == "blocks" + + def test_get_related_issues_includes_inverse_relations(self): + forward_response = { + "issue": { + "relations": { + "nodes": [ + { + "type": "related", + "relatedIssue": { + "id": "id-1", + "identifier": "ENG-1", + "title": "Forward relation", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "unstarted"}, + "assignee": None, + }, + }, + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + inverse_response = { + "issue": { + "inverseRelations": { + "nodes": [ + { + "type": "related", + "issue": { + "id": "id-2", + "identifier": "INF-100", + "title": "Inverse relation", + "url": "https://linear.app/t/INF-100", + "state": {"type": "started"}, + "assignee": None, + }, + }, + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + with patch.object( + service, "_graphql", side_effect=[forward_response, inverse_response] + ): + issues = service.get_related_issues("parent-id") + + assert issues is not None + assert len(issues) == 2 + assert issues[0]["identifier"] == "ENG-1" + assert issues[1]["identifier"] == "INF-100" + + def test_get_related_issues_deduplicates_across_directions(self): + forward_response = { + "issue": { + "relations": { + "nodes": [ + { + "type": "related", + "relatedIssue": { + "id": "id-1", + "identifier": "ENG-1", + "title": "Same issue", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "unstarted"}, + "assignee": None, + }, + }, + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + inverse_response = { + "issue": { + "inverseRelations": { + "nodes": [ + { + "type": "related", + "issue": { + "id": "id-1", + "identifier": "ENG-1", + "title": "Same issue", + "url": "https://linear.app/t/ENG-1", + "state": {"type": "unstarted"}, + "assignee": None, + }, + }, + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + with patch.object( + service, "_graphql", side_effect=[forward_response, inverse_response] + ): + issues = service.get_related_issues("parent-id") + + assert issues is not None + assert len(issues) == 1 + + def test_update_issue(self): + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + with patch.object( + service, "_graphql", return_value={"issueUpdate": {"success": True}} + ) as mock_gql: + result = service.update_issue("issue-id", title="New title") + + assert result is True + call_args = mock_gql.call_args + assert call_args[0][1]["input"]["title"] == "New title" + + def test_get_workflow_states_caches(self): + with patch("firetower.integrations.services.linear.settings") as mock_settings: + mock_settings.LINEAR = { + "CLIENT_ID": "test-id", + "CLIENT_SECRET": "test-secret", + } + service = LinearService() + + mock_response = { + "team": { + "states": { + "nodes": [ + {"id": "s1", "name": "Backlog", "type": "backlog"}, + {"id": "s2", "name": "Todo", "type": "unstarted"}, + {"id": "s3", "name": "In Progress", "type": "started"}, + {"id": "s4", "name": "Done", "type": "completed"}, + {"id": "s5", "name": "Cancelled", "type": "cancelled"}, + ] + } + } + } + + with patch.object(service, "_graphql", return_value=mock_response) as mock_gql: + states = service.get_workflow_states("team-1") + assert states["completed"] == "s4" + assert states["backlog"] == "s1" + + states2 = service.get_workflow_states("team-1") + assert states2 is states + assert mock_gql.call_count == 1 + + +@pytest.mark.django_db +class TestCreateLinearParentIssueHook: + @patch("firetower.incidents.hooks.LinearService") + @patch("firetower.incidents.hooks.settings") + def test_claims_precreated_issue(self, mock_settings, MockLinearService): + mock_settings.LINEAR = { + "TEAM_ID": "team-1", + "PROJECT_ID": "", + "SYNC_IDENTIFIERS": True, + } + mock_settings.FIRETOWER_BASE_URL = "https://firetower.example.com" + + mock_service = MockLinearService.return_value + mock_service.get_issue.return_value = { + "id": "linear-issue-id", + "identifier": "INC-100", + "title": "Placeholder", + "url": "https://linear.app/t/INC-100", + } + mock_service.get_workflow_states.return_value = {"started": "started-id"} + mock_service.update_issue.return_value = True + mock_service.create_attachment.return_value = True + + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + _create_linear_parent_issue(incident) + + incident.refresh_from_db() + assert incident.linear_parent_issue_id == "linear-issue-id" + + linear_link = ExternalLink.objects.get( + incident=incident, type=ExternalLinkType.LINEAR + ) + assert linear_link.url == "https://linear.app/t/INC-100" + + mock_service.create_issue.assert_not_called() + mock_service.update_issue.assert_called_once() + mock_service.create_attachment.assert_called_once_with( + "linear-issue-id", + f"https://firetower.example.com/{incident.incident_number}", + f"Firetower: {incident.incident_number}", + ) + + @patch("firetower.incidents.hooks.LinearService") + @patch("firetower.incidents.hooks.settings") + def test_creates_placeholder_when_not_precreated( + self, mock_settings, MockLinearService + ): + mock_settings.LINEAR = { + "TEAM_ID": "team-1", + "PROJECT_ID": "", + "SYNC_IDENTIFIERS": True, + } + mock_settings.FIRETOWER_BASE_URL = "https://firetower.example.com" + + mock_service = MockLinearService.return_value + issue_data = { + "id": "linear-issue-id", + "identifier": "INC-100", + "title": "Placeholder", + "url": "https://linear.app/t/INC-100", + } + mock_service.get_issue.side_effect = [None, issue_data] + mock_service.create_issue.return_value = { + "id": "placeholder-id", + "identifier": "INC-99", + "url": "https://linear.app/t/INC-99", + } + mock_service.get_workflow_states.return_value = {"started": "started-id"} + mock_service.update_issue.return_value = True + mock_service.create_attachment.return_value = True + + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + _create_linear_parent_issue(incident) + + incident.refresh_from_db() + assert incident.linear_parent_issue_id == "linear-issue-id" + mock_service.create_issue.assert_called_once_with( + "Placeholder", "", "team-1", None + ) + + @patch("firetower.incidents.hooks.settings") + def test_skips_when_no_team_id(self, mock_settings): + mock_settings.LINEAR = {"TEAM_ID": ""} + + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + _create_linear_parent_issue(incident) + + assert not ExternalLink.objects.filter( + incident=incident, type=ExternalLinkType.LINEAR + ).exists() + + @patch("firetower.incidents.hooks.LinearService") + @patch("firetower.incidents.hooks.settings") + def test_cleans_up_on_claim_failure(self, mock_settings, MockLinearService): + mock_settings.LINEAR = { + "TEAM_ID": "team-1", + "PROJECT_ID": "", + "SYNC_IDENTIFIERS": True, + } + mock_settings.FIRETOWER_BASE_URL = "https://firetower.example.com" + + mock_service = MockLinearService.return_value + mock_service.get_issue.return_value = None + mock_service.create_issue.return_value = None + + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + _create_linear_parent_issue(incident) + + assert not ExternalLink.objects.filter( + incident=incident, type=ExternalLinkType.LINEAR + ).exists() + assert incident.linear_parent_issue_id is None + + @patch("firetower.incidents.hooks.LinearService") + @patch("firetower.incidents.hooks.settings") + def test_skips_when_link_already_exists(self, mock_settings, MockLinearService): + mock_settings.LINEAR = {"TEAM_ID": "team-1", "PROJECT_ID": ""} + + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.LINEAR, + url="https://linear.app/existing", + ) + + _create_linear_parent_issue(incident) + + MockLinearService.return_value.get_issue.assert_not_called() + + @patch("firetower.incidents.hooks.LinearService") + @patch("firetower.incidents.hooks.settings") + def test_creates_new_issue_when_sync_identifiers_disabled( + self, mock_settings, MockLinearService + ): + mock_settings.LINEAR = { + "TEAM_ID": "team-1", + "PROJECT_ID": "proj-1", + "SYNC_IDENTIFIERS": False, + } + mock_settings.FIRETOWER_BASE_URL = "https://firetower.example.com" + + mock_service = MockLinearService.return_value + mock_service.create_issue.return_value = { + "id": "new-issue-id", + "identifier": "ENG-200", + "url": "https://linear.app/t/ENG-200", + } + mock_service.create_attachment.return_value = True + + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + _create_linear_parent_issue(incident) + + incident.refresh_from_db() + assert incident.linear_parent_issue_id == "new-issue-id" + + linear_link = ExternalLink.objects.get( + incident=incident, type=ExternalLinkType.LINEAR + ) + assert linear_link.url == "https://linear.app/t/ENG-200" + + mock_service.get_issue.assert_not_called() + mock_service.create_issue.assert_called_once() + + +@pytest.mark.django_db +class TestTitleChangeLinearSync: + @patch("firetower.incidents.hooks.LinearService") + def test_updates_linear_title(self, MockLinearService): + mock_service = MockLinearService.return_value + mock_service.update_issue.return_value = True + + incident = Incident.objects.create( + title="Updated Title", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + linear_parent_issue_id="linear-issue-id", + ) + + with patch("firetower.incidents.hooks._get_channel_id", return_value=None): + on_title_changed(incident) + + mock_service.update_issue.assert_called_once_with( + "linear-issue-id", + title=f"[{incident.incident_number}] Updated Title", + ) + + @patch("firetower.incidents.hooks.LinearService") + def test_skips_when_no_parent_issue(self, MockLinearService): + incident = Incident.objects.create( + title="Test", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + + with patch("firetower.incidents.hooks._get_channel_id", return_value=None): + on_title_changed(incident) + + MockLinearService.return_value.update_issue.assert_not_called() + + @patch("firetower.incidents.hooks.LinearService") + def test_redacts_title_for_private_incident(self, MockLinearService): + mock_service = MockLinearService.return_value + mock_service.update_issue.return_value = True + + incident = Incident.objects.create( + title="Secret Outage", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + linear_parent_issue_id="linear-issue-id", + is_private=True, + ) + + with patch("firetower.incidents.hooks._get_channel_id", return_value=None): + on_title_changed(incident) + + mock_service.update_issue.assert_called_once_with( + "linear-issue-id", + title=f"[{incident.incident_number}] Private Incident", + ) + + +@pytest.mark.django_db +class TestCreateLinearParentIssuePrivacy: + @patch("firetower.incidents.hooks.LinearService") + @patch("firetower.incidents.hooks.settings") + def test_creates_with_redacted_title_for_private_incident( + self, mock_settings, MockLinearService + ): + mock_settings.LINEAR = { + "TEAM_ID": "team-1", + "PROJECT_ID": "", + "SYNC_IDENTIFIERS": True, + } + mock_settings.FIRETOWER_BASE_URL = "https://firetower.example.com" + + mock_service = MockLinearService.return_value + mock_service.get_issue.return_value = { + "id": "linear-issue-id", + "identifier": "INC-100", + "title": "Placeholder", + "url": "https://linear.app/t/INC-100", + } + mock_service.get_workflow_states.return_value = {"started": "started-id"} + mock_service.update_issue.return_value = True + mock_service.create_attachment.return_value = True + + incident = Incident.objects.create( + title="Secret Outage", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + is_private=True, + ) + + _create_linear_parent_issue(incident) + + call_args = mock_service.update_issue.call_args + assert call_args[1]["title"] == f"[{incident.incident_number}] Private Incident" + + +@pytest.mark.django_db +class TestVisibilityChangeLinearSync: + @patch("firetower.incidents.hooks.LinearService") + def test_redacts_title_when_made_private(self, MockLinearService): + mock_service = MockLinearService.return_value + mock_service.update_issue.return_value = True + + incident = Incident.objects.create( + title="Visible Outage", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + linear_parent_issue_id="linear-issue-id", + is_private=True, + ) + + with patch("firetower.incidents.hooks._get_channel_id", return_value=None): + on_visibility_changed(incident) + + mock_service.update_issue.assert_called_once_with( + "linear-issue-id", + title=f"[{incident.incident_number}] Private Incident", + ) + + @patch("firetower.incidents.hooks.LinearService") + def test_restores_title_when_made_public(self, MockLinearService): + mock_service = MockLinearService.return_value + mock_service.update_issue.return_value = True + + incident = Incident.objects.create( + title="Now Public Outage", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + linear_parent_issue_id="linear-issue-id", + is_private=False, + ) + + with patch("firetower.incidents.hooks._get_channel_id", return_value=None): + on_visibility_changed(incident) + + mock_service.update_issue.assert_called_once_with( + "linear-issue-id", + title=f"[{incident.incident_number}] Now Public Outage", + ) + + @patch("firetower.incidents.hooks.LinearService") + def test_skips_when_no_parent_issue(self, MockLinearService): + incident = Incident.objects.create( + title="Test", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + is_private=True, + ) + + with patch("firetower.incidents.hooks._get_channel_id", return_value=None): + on_visibility_changed(incident) + + MockLinearService.return_value.update_issue.assert_not_called() + + +@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" + assert response.data[0]["relation_type"] == "child" + + 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 91556adb..bc843f65 100644 --- a/src/firetower/incidents/views.py +++ b/src/firetower/incidents/views.py @@ -23,6 +23,7 @@ filter_by_tags, ) from .models import ( + ActionItem, Incident, IncidentOrRedirect, ServiceTier, @@ -39,6 +40,7 @@ get_year_periods, ) from .serializers import ( + ActionItemSerializer, IncidentListUISerializer, IncidentOrRedirectReadSerializer, IncidentReadSerializer, @@ -46,7 +48,12 @@ 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 ( region_names_in_grouping, sort_tags_with_overrides, @@ -56,6 +63,19 @@ 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. @@ -121,18 +141,7 @@ 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) - - 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)) + numeric_id = parse_incident_id(incident_id) # Get incident by numeric ID queryset = self.get_queryset() @@ -230,19 +239,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) @@ -290,24 +287,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 @@ -340,6 +323,77 @@ 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[ActionItem]: + 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) + self.check_object_permissions(self.request, self._incident) + 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_object(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_object() + + 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/migrations/0001_linearoauthtoken.py b/src/firetower/integrations/migrations/0001_linearoauthtoken.py new file mode 100644 index 00000000..fc6890c2 --- /dev/null +++ b/src/firetower/integrations/migrations/0001_linearoauthtoken.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="LinearOAuthToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("access_token", models.TextField()), + ("expires_at", models.DateTimeField()), + ("last_refreshed", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Linear OAuth Token", + }, + ), + ] diff --git a/src/firetower/integrations/migrations/0002_alter_linearoauthtoken_access_token.py b/src/firetower/integrations/migrations/0002_alter_linearoauthtoken_access_token.py new file mode 100644 index 00000000..35af45f4 --- /dev/null +++ b/src/firetower/integrations/migrations/0002_alter_linearoauthtoken_access_token.py @@ -0,0 +1,16 @@ +import encrypted_fields.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("integrations", "0001_linearoauthtoken"), + ] + + operations = [ + migrations.AlterField( + model_name="linearoauthtoken", + name="access_token", + field=encrypted_fields.fields.EncryptedTextField(), + ), + ] diff --git a/src/firetower/integrations/models.py b/src/firetower/integrations/models.py index 6b202199..a24f2e8d 100644 --- a/src/firetower/integrations/models.py +++ b/src/firetower/integrations/models.py @@ -1 +1,18 @@ -# Create your models here. +from django.db import models +from encrypted_fields.fields import EncryptedTextField + + +class LinearOAuthToken(models.Model): + access_token = EncryptedTextField() + expires_at = models.DateTimeField() + last_refreshed = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Linear OAuth Token" + + def __str__(self) -> str: + return f"LinearOAuthToken (expires {self.expires_at})" + + @classmethod + def get_singleton(cls) -> "LinearOAuthToken | None": + return cls.objects.first() diff --git a/src/firetower/integrations/services/__init__.py b/src/firetower/integrations/services/__init__.py index c8de65be..54923568 100644 --- a/src/firetower/integrations/services/__init__.py +++ b/src/firetower/integrations/services/__init__.py @@ -1,12 +1,14 @@ """Services package for external integrations.""" from .datadog import DatadogService +from .linear import LinearService from .pagerduty import PagerDutyService from .slack import SlackService from .statuspage import StatuspageService __all__ = [ "DatadogService", + "LinearService", "PagerDutyService", "SlackService", "StatuspageService", diff --git a/src/firetower/integrations/services/linear.py b/src/firetower/integrations/services/linear.py new file mode 100644 index 00000000..faf9a3bb --- /dev/null +++ b/src/firetower/integrations/services/linear.py @@ -0,0 +1,450 @@ +import logging +from datetime import timedelta +from typing import Any + +import requests +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from firetower.integrations.models import LinearOAuthToken + +logger = logging.getLogger(__name__) + +LINEAR_API_URL = "https://api.linear.app/graphql" +LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token" + +LINEAR_STATE_TYPE_MAP = { + "triage": "Todo", + "backlog": "Todo", + "unstarted": "Todo", + "started": "In Progress", + "completed": "Done", + "cancelled": "Cancelled", +} + +LINEAR_RELATION_TYPE_MAP = { + "related": "related", + "blocks": "blocks", + "blocked": "blocked_by", + "duplicate": "duplicate", +} + +TOKEN_LIFETIME = timedelta(days=30) +TOKEN_REFRESH_BUFFER = timedelta(days=1) + +ISSUE_FIELDS = """ + id + identifier + title + url + state { + type + } + assignee { + id + email + } +""" + + +class LinearService: + def __init__(self) -> None: + linear_config = settings.LINEAR + self.client_id = linear_config.get("CLIENT_ID") + self.client_secret = linear_config.get("CLIENT_SECRET") + self._workflow_states_cache: dict[str, str] | None = None + + def _request_new_token(self) -> str | None: + try: + response = requests.post( + LINEAR_TOKEN_URL, + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": "read,write", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + access_token = data["access_token"] + + expires_in = data.get("expires_in") + if expires_in is not None: + expires_at = timezone.now() + timedelta(seconds=expires_in) + else: + expires_at = timezone.now() + TOKEN_LIFETIME + + with transaction.atomic(): + LinearOAuthToken.objects.select_for_update().all().delete() + LinearOAuthToken.objects.create( + access_token=access_token, + expires_at=expires_at, + ) + + logger.info("Obtained new Linear OAuth token") + return access_token + except requests.RequestException: + logger.exception("Failed to obtain Linear OAuth token") + return None + except (KeyError, ValueError): + logger.exception("Unexpected response from Linear token endpoint") + return None + + def _get_access_token(self) -> str | None: + if not self.client_id or not self.client_secret: + return None + + token = LinearOAuthToken.get_singleton() + if token and token.expires_at > timezone.now() + TOKEN_REFRESH_BUFFER: + return token.access_token + + return self._request_new_token() + + def _make_graphql_request( + self, query: str, variables: dict | None, access_token: str + ) -> requests.Response: + return requests.post( + LINEAR_API_URL, + json={"query": query, "variables": variables or {}}, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + timeout=30, + ) + + def _graphql(self, query: str, variables: dict | None = None) -> dict | None: + access_token = self._get_access_token() + if not access_token: + logger.warning( + "Cannot make Linear API call - no valid access token available" + ) + return None + + try: + response = self._make_graphql_request(query, variables, access_token) + + if response.status_code == 401: + logger.info("Linear token expired, requesting new token") + access_token = self._request_new_token() + if not access_token: + return None + + response = self._make_graphql_request(query, variables, access_token) + + if not response.ok: + logger.error( + f"Linear API returned {response.status_code}: {response.text}" + ) + 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 _parse_issue( + self, issue: dict[str, Any], relation_type: str = "child" + ) -> dict[str, Any]: + state_type = issue.get("state", {}).get("type", "") + status = LINEAR_STATE_TYPE_MAP.get(state_type, "Todo") + assignee = issue.get("assignee") or {} + return { + "id": issue["id"], + "identifier": issue["identifier"], + "title": issue["title"], + "url": issue["url"], + "status": status, + "assignee_email": assignee.get("email"), + "assignee_linear_id": assignee.get("id"), + "relation_type": relation_type, + } + + def get_issue(self, identifier: str) -> dict[str, Any] | None: + query = f""" + query($id: String!) {{ + issue(id: $id) {{ + {ISSUE_FIELDS} + }} + }} + """ + data = self._graphql(query, {"id": identifier}) + if not data or not data.get("issue"): + return None + issue = data["issue"] + return { + "id": issue["id"], + "identifier": issue["identifier"], + "title": issue["title"], + "url": issue["url"], + } + + def create_issue( + self, + title: str, + description: str, + team_id: str, + project_id: str | None = None, + state_id: str | None = None, + ) -> dict[str, Any] | None: + mutation = """ + mutation($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + url + } + } + } + """ + input_data: dict[str, Any] = { + "title": title, + "description": description, + "teamId": team_id, + } + if project_id: + input_data["projectId"] = project_id + if state_id: + input_data["stateId"] = state_id + + data = self._graphql(mutation, {"input": input_data}) + if not data: + return None + + result = data.get("issueCreate", {}) + if not result.get("success"): + logger.error("Linear issueCreate failed", extra={"result": result}) + return None + + issue = result.get("issue") + if not issue: + logger.error("Linear issueCreate returned no issue") + return None + + return { + "id": issue["id"], + "identifier": issue["identifier"], + "url": issue["url"], + } + + def create_attachment(self, issue_id: str, url: str, title: str) -> bool: + mutation = """ + mutation($input: AttachmentCreateInput!) { + attachmentCreate(input: $input) { + success + } + } + """ + data = self._graphql( + mutation, + {"input": {"issueId": issue_id, "url": url, "title": title}}, + ) + if not data: + return False + + return data.get("attachmentCreate", {}).get("success", False) + + def update_issue( + self, + issue_id: str, + title: str | None = None, + description: str | None = None, + state_id: str | None = None, + ) -> bool: + mutation = """ + mutation($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + } + } + """ + input_data: dict[str, Any] = {} + if title is not None: + input_data["title"] = title + if description is not None: + input_data["description"] = description + if state_id is not None: + input_data["stateId"] = state_id + + if not input_data: + return True + + data = self._graphql(mutation, {"id": issue_id, "input": input_data}) + if not data: + return False + + return data.get("issueUpdate", {}).get("success", False) + + def get_workflow_states(self, team_id: str) -> dict[str, str] | None: + if self._workflow_states_cache is not None: + return self._workflow_states_cache + + query = """ + query($teamId: String!) { + team(id: $teamId) { + states { + nodes { + id + name + type + } + } + } + } + """ + data = self._graphql(query, {"teamId": team_id}) + if not data: + return None + + team = data.get("team") + if not team: + return None + + states: dict[str, str] = {} + for node in team.get("states", {}).get("nodes", []): + state_type = node.get("type", "") + if state_type not in states: + states[state_type] = node["id"] + + self._workflow_states_cache = states + return states + + def get_child_issues(self, issue_id: str) -> list[dict[str, Any]] | None: + query = f""" + query($issueId: String!, $after: String) {{ + issue(id: $issueId) {{ + children(first: 50, after: $after) {{ + nodes {{ + {ISSUE_FIELDS} + }} + pageInfo {{ + hasNextPage + endCursor + }} + }} + }} + }} + """ + + issues: list[dict[str, Any]] = [] + cursor: str | None = None + max_pages = 25 + + for _ in range(max_pages): + variables: dict[str, Any] = {"issueId": issue_id} + if cursor is not None: + variables["after"] = cursor + + data = self._graphql(query, variables) + if data is None: + return None + + issue = data.get("issue") + if not issue: + return None + + children = issue.get("children", {}) + issues.extend( + self._parse_issue(node, "child") for node in children.get("nodes", []) + ) + + page_info = children.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + cursor = page_info.get("endCursor") + if cursor is None: + break + + return issues + + def _fetch_relations( + self, + issue_id: str, + field: str, + issue_key: str, + seen_ids: set[str], + ) -> list[dict[str, Any]] | None: + query = f""" + query($issueId: String!, $after: String) {{ + issue(id: $issueId) {{ + {field}(first: 50, after: $after) {{ + nodes {{ + type + {issue_key} {{ + {ISSUE_FIELDS} + }} + }} + pageInfo {{ + hasNextPage + endCursor + }} + }} + }} + }} + """ + + issues: list[dict[str, Any]] = [] + cursor: str | None = None + max_pages = 25 + + for _ in range(max_pages): + variables: dict[str, Any] = {"issueId": issue_id} + if cursor is not None: + variables["after"] = cursor + + data = self._graphql(query, variables) + if data is None: + return None + + issue = data.get("issue") + if not issue: + return None + + relations = issue.get(field, {}) + for node in relations.get("nodes", []): + related_issue = node.get(issue_key) + if not related_issue or "id" not in related_issue: + continue + if related_issue["id"] in seen_ids: + continue + seen_ids.add(related_issue["id"]) + + linear_type = node.get("type", "").lower() + relation_type = LINEAR_RELATION_TYPE_MAP.get(linear_type, "related") + issues.append(self._parse_issue(related_issue, relation_type)) + + page_info = relations.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + cursor = page_info.get("endCursor") + if cursor is None: + break + + return issues + + def get_related_issues(self, issue_id: str) -> list[dict[str, Any]] | None: + seen_ids: set[str] = set() + + forward = self._fetch_relations(issue_id, "relations", "relatedIssue", seen_ids) + if forward is None: + return None + + inverse = self._fetch_relations(issue_id, "inverseRelations", "issue", seen_ids) + if inverse is None: + return None + + return forward + inverse diff --git a/src/firetower/settings.py b/src/firetower/settings.py index fcb94dac..bc139318 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -69,12 +69,16 @@ def _coerce_region_grouping(raw: list[Any]) -> list[list[str]]: # Global project settings PROJECT_KEY = config.project_key REGION_GROUPING = _coerce_region_grouping(config.region_grouping) +FIRETOWER_BASE_URL = config.firetower_base_url # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ SECRET_KEY = config.django_secret_key +# Used by django-fernet-encrypted-fields to encrypt sensitive DB fields (e.g. OAuth tokens). +SALT_KEY = config.salt_key + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env_is_dev() @@ -253,8 +257,6 @@ class StatuspageSettings(TypedDict): else None ) -FIRETOWER_BASE_URL = config.firetower_base_url - NOTION: dict | None = ( { "INTEGRATION_TOKEN": config.notion.integration_token, @@ -291,6 +293,23 @@ class StatuspageSettings(TypedDict): else None ) +# Linear Integration Configuration +LINEAR = ( + { + "CLIENT_ID": config.linear.client_id, + "CLIENT_SECRET": config.linear.client_secret, + "TEAM_ID": config.linear.team_id, + "PROJECT_ID": config.linear.project_id, + "SYNC_IDENTIFIERS": config.linear.sync_identifiers, + } + 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 diff --git a/uv.lock b/uv.lock index 720e94ca..d4021c3c 100644 --- a/uv.lock +++ b/uv.lock @@ -467,6 +467,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] +[[package]] +name = "django-fernet-encrypted-fields" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/aa/529af3888215b8a660fc3897d6d63eaf1de9aa0699c633ca0ec483d4361c/django_fernet_encrypted_fields-0.3.1.tar.gz", hash = "sha256:5ed328c7f9cc7f2d452bb2e125f3ea2bea3563a259fa943e5a1c626175889a71", size = 5265, upload-time = "2025-11-10T08:39:57.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/7f/4e0b7ed8413fa58e7a77017342e8ab0e977d41cfc376ab9180ae75f216ec/django_fernet_encrypted_fields-0.3.1-py3-none-any.whl", hash = "sha256:3bd2abab02556dc6e15a58a61161ee6c5cdf45a50a8a52d9e035009eb54c6442", size = 5484, upload-time = "2025-11-10T08:39:55.866Z" }, +] + [[package]] name = "django-kubernetes" version = "1.1.0" @@ -548,6 +561,7 @@ dependencies = [ { name = "ddtrace" }, { name = "django" }, { name = "django-cors-headers" }, + { name = "django-fernet-encrypted-fields" }, { name = "django-kubernetes" }, { name = "djangorestframework" }, { name = "google-auth" }, @@ -587,6 +601,7 @@ requires-dist = [ { name = "ddtrace", specifier = "==3.18.1" }, { name = "django", specifier = ">=5.2.14,<6" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, + { name = "django-fernet-encrypted-fields", specifier = ">=0.3.1" }, { name = "django-kubernetes", specifier = ">=1.1.0" }, { name = "djangorestframework", specifier = ">=3.15.2" }, { name = "google-auth", specifier = ">=2.37.0" },