diff --git a/src/firefighter/incidents/factories.py b/src/firefighter/incidents/factories.py index d4e0795b..70928b10 100644 --- a/src/firefighter/incidents/factories.py +++ b/src/firefighter/incidents/factories.py @@ -15,6 +15,7 @@ Group, Incident, IncidentCategory, + IncidentUpdate, Priority, User, ) @@ -100,3 +101,14 @@ class Meta: environment = Iterator(Environment.objects.all()) # type: ignore[no-untyped-call] created_by = SubFactory(UserFactory) # type: ignore[no-untyped-call] + + +class IncidentUpdateFactory(DjangoModelFactory[IncidentUpdate]): + """Factory for creating IncidentUpdate instances in tests.""" + + class Meta: + model = IncidentUpdate + + incident = SubFactory(IncidentFactory) # type: ignore[no-untyped-call] + message = Faker("text", max_nb_chars=100) # type: ignore[no-untyped-call] + created_by = SubFactory(UserFactory) # type: ignore[no-untyped-call] diff --git a/src/firefighter/incidents/models/__init__.py b/src/firefighter/incidents/models/__init__.py index abec71ed..be41c9cc 100644 --- a/src/firefighter/incidents/models/__init__.py +++ b/src/firefighter/incidents/models/__init__.py @@ -7,6 +7,10 @@ from firefighter.incidents.models.incident_category import IncidentCategory from firefighter.incidents.models.incident_cost import IncidentCost from firefighter.incidents.models.incident_cost_type import IncidentCostType +from firefighter.incidents.models.incident_membership import ( + IncidentMembership, + IncidentRole, +) from firefighter.incidents.models.incident_role_type import IncidentRoleType from firefighter.incidents.models.incident_update import IncidentUpdate from firefighter.incidents.models.milestone_type import MilestoneType diff --git a/src/firefighter/raid/client.py b/src/firefighter/raid/client.py index 15ed998e..e74379a8 100644 --- a/src/firefighter/raid/client.py +++ b/src/firefighter/raid/client.py @@ -6,7 +6,7 @@ from django.conf import settings from httpx import HTTPError -from jira.exceptions import JIRAError +from jira import JIRAError from firefighter.firefighter.http_client import HttpClient from firefighter.firefighter.utils import get_in @@ -35,6 +35,9 @@ class JiraAttachmentError(Exception): class RaidJiraClient(JiraClient): + def __init__(self) -> None: + super().__init__() + def create_issue( # noqa: PLR0912, PLR0913, C901, PLR0917 self, issuetype: str | None, @@ -163,6 +166,62 @@ def close_issue( issue_id, TARGET_STATUS_NAME, RAID_JIRA_WORKFLOW_NAME ) + def update_issue( + self, + issue_key: str, + fields: dict[str, Any], + ) -> bool: + """Update fields on a Jira issue. + + Args: + issue_key: The Jira issue key (e.g., 'INC-123') + fields: Dictionary of fields to update + + Returns: + True if update was successful, False otherwise + """ + try: + self.jira.issue(issue_key).update(fields=fields) + logger.info(f"Updated Jira issue {issue_key} with fields: {list(fields.keys())}") + except JIRAError: + logger.exception(f"Failed to update Jira issue {issue_key}") + return False + else: + return True + + def transition_issue( + self, + issue_key: str, + target_status: str, + ) -> bool: + """Transition a Jira issue to a target status. + + Args: + issue_key: The Jira issue key (e.g., 'INC-123') + target_status: The target status name + + Returns: + True if transition was successful, False otherwise + """ + try: + issue = self.jira.issue(issue_key) + transitions = self.jira.transitions(issue) + + # Find the transition that leads to the target status + for transition in transitions: + if transition["to"]["name"] == target_status: + self.jira.transition_issue(issue, transition["id"]) + logger.info(f"Transitioned Jira issue {issue_key} to status: {target_status}") + return True + + logger.warning( + f"No transition found to status '{target_status}' for issue {issue_key}" + ) + except JIRAError: + logger.exception(f"Failed to transition Jira issue {issue_key}") + + return False + def _get_project_config_workflow( self, project_key: str = RAID_JIRA_PROJECT_KEY ) -> dict[str, Any]: diff --git a/src/firefighter/raid/forms.py b/src/firefighter/raid/forms.py index d840654c..7d860cef 100644 --- a/src/firefighter/raid/forms.py +++ b/src/firefighter/raid/forms.py @@ -393,6 +393,66 @@ def alert_slack_new_jira_ticket( ) +def send_message_to_incident_channel( + jira_ticket_id: int, + jira_field_modified: str, + message: SlackMessageSurface +) -> bool: + """Send notification to incident channel for critical Jira changes. + + Only sends notifications for critical fields that warrant incident team attention: + - status: Status changes affect incident workflow + - Priority: Priority changes affect urgency and response + + Args: + jira_ticket_id: The Jira ticket ID + jira_field_modified: The field that was modified + message: The Slack message to send + + Returns: + True if successful or no channel to notify, False if failed + """ + # Only notify incident channel for critical changes + critical_fields = {"status", "Priority"} + if jira_field_modified not in critical_fields: + logger.debug( + f"Field '{jira_field_modified}' is not critical, skipping incident channel notification" + ) + return True + + try: + jira_ticket = JiraTicket.objects.select_related("incident").get(id=jira_ticket_id) + + # Check if ticket is linked to an incident + if not jira_ticket.incident: + logger.debug(f"Jira ticket {jira_ticket_id} has no linked incident") + return True + + # Check if incident has a Slack channel + channel = getattr(jira_ticket.incident, "incidentchannel", None) + if channel is None: + logger.debug(f"Incident {jira_ticket.incident.id} has no Slack channel") + return True + + # Send message to incident channel + channel.send_message_and_save(message) + logger.info( + f"Sent Jira update notification to incident channel {channel.name} " + f"for ticket {jira_ticket.key} (field: {jira_field_modified})" + ) + + except JiraTicket.DoesNotExist: + logger.warning(f"Jira ticket with ID {jira_ticket_id} not found") + return False + except Exception: + logger.exception( + f"Failed to send message to incident channel for ticket {jira_ticket_id}" + ) + return False + else: + return True + + def alert_slack_update_ticket( jira_ticket_id: int, jira_ticket_key: str, @@ -408,7 +468,14 @@ def alert_slack_update_ticket( jira_field_from=jira_field_from, jira_field_to=jira_field_to, ) - return send_message_to_watchers(jira_issue_id=jira_ticket_id, message=message) + + # Send notifications to watchers (existing behavior) + watchers_success = send_message_to_watchers(jira_issue_id=jira_ticket_id, message=message) + + # Send notification to incident channel for critical changes + channel_success = send_message_to_incident_channel(jira_ticket_id, jira_field_modified, message) + + return watchers_success and channel_success def alert_slack_comment_ticket( diff --git a/src/firefighter/raid/serializers.py b/src/firefighter/raid/serializers.py index a73f291f..615cf4a2 100644 --- a/src/firefighter/raid/serializers.py +++ b/src/firefighter/raid/serializers.py @@ -19,6 +19,7 @@ alert_slack_update_ticket, ) from firefighter.raid.models import JiraTicket +from firefighter.raid.sync import handle_jira_webhook_update from firefighter.raid.utils import get_domain_from_email from firefighter.slack.models.user import SlackUser @@ -264,6 +265,16 @@ class JiraWebhookUpdateSerializer(serializers.Serializer[Any]): ) def create(self, validated_data: dict[str, Any]) -> bool: + # First, sync the changes to Impact incident + sync_success = handle_jira_webhook_update( + issue_data=validated_data["issue"], + changelog_data=validated_data["changelog"], + ) + + if not sync_success: + logger.warning("Failed to sync Jira changes to Impact incident") + + # Then, notify Slack about the changes jira_field_modified = validated_data["changelog"].get("items")[0].get("field") if jira_field_modified in { "Priority", diff --git a/src/firefighter/raid/signals/__init__.py b/src/firefighter/raid/signals/__init__.py index e69de29b..153b21fe 100644 --- a/src/firefighter/raid/signals/__init__.py +++ b/src/firefighter/raid/signals/__init__.py @@ -0,0 +1,8 @@ +"""RAID signal handlers for incident synchronization.""" + +# Import all signal handlers to ensure they are registered +from __future__ import annotations + +from firefighter.raid.signals.incident_created import * # noqa: F403 +from firefighter.raid.signals.incident_updated import * # noqa: F403 +from firefighter.raid.signals.incident_updated_sync import * # noqa: F403 diff --git a/src/firefighter/raid/signals/incident_updated_sync.py b/src/firefighter/raid/signals/incident_updated_sync.py new file mode 100644 index 00000000..c7de2aca --- /dev/null +++ b/src/firefighter/raid/signals/incident_updated_sync.py @@ -0,0 +1,137 @@ +"""Signal handler for syncing incident updates to Jira.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +from firefighter.incidents.models import Incident, IncidentUpdate +from firefighter.raid.sync import sync_incident_to_jira + +if TYPE_CHECKING: + from django.db.models import Model + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=Incident) +def sync_incident_changes_to_jira( + sender: type[Model], instance: Incident, *, created: bool, **kwargs: Any +) -> None: + """Sync incident changes to the linked Jira ticket. + + Args: + sender: The model class (Incident) + instance: The incident instance that was saved + created: Whether this is a new instance + **kwargs: Additional keyword arguments including update_fields + """ + # Skip sync for new incidents (they're handled by the creation signal) + if created: + return + + # Check if RAID is enabled + if not getattr(settings, "ENABLE_RAID", False): + return + + # Get the list of updated fields + update_fields = kwargs.get("update_fields") + if not update_fields: + # If no specific fields were updated, assume all fields + # In practice, we should always use update_fields when saving + logger.debug( + f"No update_fields specified for incident {instance.id} save - skipping sync" + ) + return + + # Fields that should trigger a sync to Jira + sync_fields = {"title", "description", "priority", "status", "commander"} + updated_sync_fields = list(set(update_fields) & sync_fields) + + if not updated_sync_fields: + logger.debug( + f"No syncable fields updated for incident {instance.id} - skipping sync" + ) + return + + # Sync to Jira + try: + sync_success = sync_incident_to_jira(instance, updated_sync_fields) + if sync_success: + logger.info( + f"Successfully synced incident {instance.id} to Jira " + f"(fields: {updated_sync_fields})" + ) + else: + logger.warning( + f"Failed to sync incident {instance.id} to Jira " + f"(fields: {updated_sync_fields})" + ) + except Exception: + logger.exception(f"Error syncing incident {instance.id} to Jira") + + +@receiver(post_save, sender=IncidentUpdate) +def sync_incident_update_to_jira( + sender: type[Model], instance: IncidentUpdate, *, created: bool, **kwargs: Any +) -> None: + """Sync incident update changes to the linked Jira ticket. + + This handles IncidentUpdate records which track field-level changes. + + Args: + sender: The model class (IncidentUpdate) + instance: The incident update instance that was saved + created: Whether this is a new instance + **kwargs: Additional keyword arguments + """ + # Only process new updates + if not created: + return + + # Check if RAID is enabled + if not getattr(settings, "ENABLE_RAID", False): + return + + incident = instance.incident + updated_fields = [] + + # Check which fields were updated in this IncidentUpdate + if instance.title and instance.title != incident.title: + updated_fields.append("title") + + if instance.description and instance.description != incident.description: + updated_fields.append("description") + + if instance.priority and instance.priority != incident.priority: + updated_fields.append("priority") + + if instance.status and instance.status != incident.status: + updated_fields.append("status") + + # Skip if this update was created by the sync process itself + if instance.created_by is None and "from Jira" in (instance.message or ""): + logger.debug( + f"Skipping sync for IncidentUpdate {instance.id} - appears to be from Jira sync" + ) + return + + if updated_fields: + try: + sync_success = sync_incident_to_jira(incident, updated_fields) + if sync_success: + logger.info( + f"Successfully synced IncidentUpdate {instance.id} to Jira " + f"(fields: {updated_fields})" + ) + else: + logger.warning( + f"Failed to sync IncidentUpdate {instance.id} to Jira " + f"(fields: {updated_fields})" + ) + except Exception: + logger.exception(f"Error syncing IncidentUpdate {instance.id} to Jira") diff --git a/src/firefighter/raid/sync.py b/src/firefighter/raid/sync.py new file mode 100644 index 00000000..20ac17cf --- /dev/null +++ b/src/firefighter/raid/sync.py @@ -0,0 +1,459 @@ +"""Synchronization utilities for bidirectional sync between Impact, Jira and Slack.""" + +from __future__ import annotations + +import logging +from enum import Enum +from typing import Any + +from django.core.cache import cache +from django.db import transaction + +from firefighter.incidents.enums import IncidentStatus +from firefighter.incidents.models import ( + Incident, + IncidentRole, + IncidentRoleType, + IncidentUpdate, + Priority, +) +from firefighter.jira_app.models import JiraUser +from firefighter.raid.client import client as jira_client +from firefighter.raid.models import JiraTicket + +logger = logging.getLogger(__name__) + +# Cache timeout for sync operations (in seconds) +SYNC_CACHE_TIMEOUT = 30 + + +class SyncDirection(Enum): + """Direction of synchronization.""" + + IMPACT_TO_JIRA = "impact_to_jira" + JIRA_TO_IMPACT = "jira_to_impact" + IMPACT_TO_SLACK = "impact_to_slack" + SLACK_TO_IMPACT = "slack_to_impact" + + +# Bidirectional status mapping between Jira and Impact +JIRA_TO_IMPACT_STATUS_MAP = { + "Open": IncidentStatus.INVESTIGATING, + "To Do": IncidentStatus.INVESTIGATING, + "In Progress": IncidentStatus.FIXING, + "In Review": IncidentStatus.FIXING, + "Resolved": IncidentStatus.FIXED, + "Done": IncidentStatus.FIXED, + "Closed": IncidentStatus.POST_MORTEM, + "Reopened": IncidentStatus.INVESTIGATING, + "Blocked": IncidentStatus.FIXING, + "Waiting": IncidentStatus.FIXING, +} + +IMPACT_TO_JIRA_STATUS_MAP = { + IncidentStatus.OPEN: "Open", + IncidentStatus.INVESTIGATING: "In Progress", + IncidentStatus.FIXING: "In Progress", + IncidentStatus.FIXED: "Resolved", + IncidentStatus.POST_MORTEM: "Closed", + IncidentStatus.CLOSED: "Closed", +} + +# Priority mapping between Jira (string) and Impact (numeric) +JIRA_TO_IMPACT_PRIORITY_MAP = { + "Highest": 1, # P1 - Critical + "High": 2, # P2 - High + "Medium": 3, # P3 - Medium + "Low": 4, # P4 - Low + "Lowest": 5, # P5 - Lowest +} + + +def should_skip_sync( + entity_type: str, entity_id: str | int, direction: SyncDirection +) -> bool: + """Check if we should skip this sync to prevent loops. + + Uses a cache-based approach to track recent syncs and prevent infinite loops. + + Args: + entity_type: Type of entity being synced (e.g., 'incident', 'jira_ticket') + entity_id: ID of the entity + direction: Direction of sync + + Returns: + True if sync should be skipped, False otherwise + """ + cache_key = f"sync:{entity_type}:{entity_id}:{direction.value}" + + # Check if this sync was recently performed + if cache.get(cache_key): + logger.debug(f"Skipping sync for {cache_key} - recent sync detected") + return True + + # Mark this sync as in progress + cache.set(cache_key, value=True, timeout=SYNC_CACHE_TIMEOUT) + return False + + +def sync_jira_status_to_incident(jira_ticket: JiraTicket, jira_status: str) -> bool: + """Sync Jira status to Impact incident status. + + Args: + jira_ticket: The JiraTicket model instance + jira_status: The new Jira status string + + Returns: + True if sync was successful, False otherwise + """ + if not jira_ticket.incident: + logger.warning( + f"JiraTicket {jira_ticket.key} has no linked incident - skipping status sync" + ) + return False + + incident = jira_ticket.incident + + # Check for sync loop + if should_skip_sync("incident", incident.id, SyncDirection.JIRA_TO_IMPACT): + return False + + # Map Jira status to Impact status + impact_status = JIRA_TO_IMPACT_STATUS_MAP.get(jira_status) + if not impact_status: + logger.warning(f"Unknown Jira status: {jira_status} - skipping sync") + return False + + # Check if status actually changed + if incident.status == impact_status: + logger.debug( + f"Incident {incident.id} already has status {impact_status} - skipping" + ) + return True + + try: + with transaction.atomic(): + old_status = incident.status + incident.status = impact_status + incident.save(update_fields=["_status"]) + + # Create an IncidentUpdate record + IncidentUpdate.objects.create( + incident=incident, + _status=impact_status, + created_by=None, # System update + message=f"Status updated from Jira: {jira_status}", + ) + + logger.info( + f"Synced Jira status '{jira_status}' to incident {incident.id} " + f"(status: {old_status} → {impact_status})" + ) + return True + + except Exception: + logger.exception("Failed to sync Jira status to incident") + return False + + +def sync_jira_priority_to_incident( + jira_ticket: JiraTicket, jira_priority: str +) -> bool: + """Sync Jira priority to Impact incident priority. + + Args: + jira_ticket: The JiraTicket model instance + jira_priority: The new Jira priority string (e.g., 'High', 'Critical') + + Returns: + True if sync was successful, False otherwise + """ + if not jira_ticket.incident: + logger.warning( + f"JiraTicket {jira_ticket.key} has no linked incident - skipping priority sync" + ) + return False + + incident = jira_ticket.incident + + # Check for sync loop + if should_skip_sync("incident", incident.id, SyncDirection.JIRA_TO_IMPACT): + return False + + # Map Jira priority to Impact priority value + priority_value = JIRA_TO_IMPACT_PRIORITY_MAP.get(jira_priority) + if not priority_value: + logger.warning(f"Unknown Jira priority: {jira_priority} - skipping sync") + return False + + try: + # Get or create the Priority object + priority = Priority.objects.get(value=priority_value) + + # Check if priority actually changed + if incident.priority == priority: + logger.debug( + f"Incident {incident.id} already has priority {priority} - skipping" + ) + return True + + with transaction.atomic(): + old_priority = incident.priority + incident.priority = priority + incident.save(update_fields=["priority"]) + + # Create an IncidentUpdate record + IncidentUpdate.objects.create( + incident=incident, + priority=priority, + created_by=None, # System update + message=f"Priority updated from Jira: {jira_priority}", + ) + + logger.info( + f"Synced Jira priority '{jira_priority}' to incident {incident.id} " + f"(priority: {old_priority} → {priority})" + ) + return True + + except Priority.DoesNotExist: + logger.exception(f"Priority with value {priority_value} does not exist") + return False + except Exception: + logger.exception("Failed to sync Jira priority to incident") + return False + + +def _sync_assignee_to_commander( + incident: Incident, assignee_data: dict[str, Any] | None +) -> None: + """Sync Jira assignee to incident commander role.""" + if not assignee_data: + return + + assignee_id = assignee_data.get("accountId") + if not assignee_id: + return + + try: + jira_user = JiraUser.objects.get(id=assignee_id) + # Get or create commander role + commander_role_type = IncidentRoleType.objects.filter( + slug="commander" + ).first() + if commander_role_type: + # Update or create commander role for this incident + IncidentRole.objects.update_or_create( + incident=incident, + role_type=commander_role_type, + defaults={"user": jira_user.user}, + ) + except JiraUser.DoesNotExist: + logger.warning(f"JiraUser with id {assignee_id} not found") + + +def sync_jira_fields_to_incident( + jira_ticket: JiraTicket, jira_fields: dict[str, Any] +) -> bool: + """Sync various Jira fields to Impact incident. + + Args: + jira_ticket: The JiraTicket model instance + jira_fields: Dictionary of Jira fields that changed + + Returns: + True if all syncs were successful, False if any failed + """ + if not jira_ticket.incident: + logger.warning( + f"JiraTicket {jira_ticket.key} has no linked incident - skipping field sync" + ) + return False + + incident = jira_ticket.incident + success = True + updated_fields = [] + + # Check for sync loop + if should_skip_sync("incident", incident.id, SyncDirection.JIRA_TO_IMPACT): + return False + + try: + with transaction.atomic(): + # Sync summary/title + if "summary" in jira_fields and jira_fields["summary"] != incident.title: + incident.title = jira_fields["summary"] + updated_fields.append("title") + + # Sync description + if ( + "description" in jira_fields + and jira_fields["description"] != incident.description + ): + incident.description = jira_fields["description"] + updated_fields.append("description") + + # Sync assignee to incident commander role + if "assignee" in jira_fields: + _sync_assignee_to_commander(incident, jira_fields.get("assignee")) + + if updated_fields: + incident.save(update_fields=updated_fields) + + # Create an IncidentUpdate record + IncidentUpdate.objects.create( + incident=incident, + created_by=None, # System update + message=f"Fields updated from Jira: {', '.join(updated_fields)}", + ) + + logger.info( + f"Synced Jira fields to incident {incident.id}: {updated_fields}" + ) + + except Exception: + logger.exception("Failed to sync Jira fields to incident") + success = False + + return success + + +def _build_jira_update_fields(incident: Incident, updated_fields: list[str]) -> dict[str, Any]: + """Build the update fields dictionary for Jira based on incident changes.""" + update_fields: dict[str, Any] = {} + + # Map Impact fields to Jira fields + if "title" in updated_fields: + update_fields["summary"] = incident.title + + if "description" in updated_fields: + update_fields["description"] = incident.description + + if "priority" in updated_fields and incident.priority: + # Map numeric priority to Jira priority string + priority_map_reverse = {v: k for k, v in JIRA_TO_IMPACT_PRIORITY_MAP.items()} + jira_priority = priority_map_reverse.get(incident.priority.value) + if jira_priority: + update_fields["priority"] = {"name": jira_priority} + + return update_fields + + +def _sync_commander_to_jira(incident: Incident) -> dict[str, Any] | None: + """Get Jira assignee field for incident commander.""" + try: + commander_role_type = IncidentRoleType.objects.filter(slug="commander").first() + if commander_role_type: + commander_role = IncidentRole.objects.filter( + incident=incident, role_type=commander_role_type + ).first() + if commander_role and commander_role.user: + jira_user = jira_client.get_jira_user_from_user(commander_role.user) + return {"assignee": {"accountId": jira_user.id}} + except (AttributeError, ValueError) as e: + logger.warning(f"Could not find Jira user for commander: {e}") + return None + + +def sync_incident_to_jira(incident: Incident, updated_fields: list[str]) -> bool: + """Sync Impact incident changes to Jira ticket. + + Args: + incident: The Incident model instance + updated_fields: List of field names that were updated + + Returns: + True if sync was successful, False otherwise + """ + try: + # Get the linked Jira ticket + jira_ticket = JiraTicket.objects.get(incident=incident) + except JiraTicket.DoesNotExist: + logger.debug(f"Incident {incident.id} has no linked Jira ticket - skipping") + return False + + # Check for sync loop + if should_skip_sync("jira_ticket", jira_ticket.id, SyncDirection.IMPACT_TO_JIRA): + return False + + try: + # Build basic field updates + update_fields = _build_jira_update_fields(incident, updated_fields) + + # Handle status separately (uses transitions, not field updates) + if "status" in updated_fields: + jira_status = IMPACT_TO_JIRA_STATUS_MAP.get(incident.status) + if jira_status and jira_client.transition_issue(jira_ticket.key, jira_status): + logger.info( + f"Transitioned Jira ticket {jira_ticket.key} to status: {jira_status}" + ) + + # Handle commander assignment + if "commander" in updated_fields: + commander_fields = _sync_commander_to_jira(incident) + if commander_fields: + update_fields.update(commander_fields) + + # Apply field updates if any + if update_fields and jira_client.update_issue(jira_ticket.key, update_fields): + logger.info( + f"Synced incident {incident.id} to Jira ticket {jira_ticket.key}: " + f"{list(update_fields.keys())}" + ) + + except Exception: + logger.exception("Failed to sync incident to Jira") + return False + else: + return True + + +def handle_jira_webhook_update( + issue_data: dict[str, Any], changelog_data: dict[str, Any] +) -> bool: + """Handle incoming Jira webhook update and sync to Impact. + + Args: + issue_data: The Jira issue data from webhook + changelog_data: The changelog data showing what changed + + Returns: + True if sync was successful, False otherwise + """ + jira_key = issue_data.get("key") + if not jira_key: + logger.error("No Jira key in webhook data") + return False + + try: + jira_ticket = JiraTicket.objects.get(key=jira_key) + except JiraTicket.DoesNotExist: + logger.warning(f"JiraTicket with key {jira_key} not found - skipping sync") + return False + + # Process each changed field + success = True + for item in changelog_data.get("items", []): + field_name = item.get("field") + new_value = item.get("toString") + + if field_name == "status": + if not sync_jira_status_to_incident(jira_ticket, new_value): + success = False + + elif field_name == "priority": + if not sync_jira_priority_to_incident(jira_ticket, new_value): + success = False + + elif field_name in {"summary", "description", "assignee"}: + # Get the full issue fields for these updates + fields = issue_data.get("fields", {}) + jira_fields = { + "summary": fields.get("summary"), + "description": fields.get("description"), + "assignee": fields.get("assignee"), + } + if not sync_jira_fields_to_incident(jira_ticket, jira_fields): + success = False + + return success diff --git a/tests/test_raid/test_raid_forms.py b/tests/test_raid/test_raid_forms.py index 5fd935a4..48284e78 100644 --- a/tests/test_raid/test_raid_forms.py +++ b/tests/test_raid/test_raid_forms.py @@ -521,14 +521,16 @@ def test_alert_slack_new_jira_ticket_messages_disabled( class TestAlertSlackUpdateTicket: """Test alert_slack_update_ticket function.""" + @patch("firefighter.raid.forms.send_message_to_incident_channel") @patch("firefighter.raid.forms.send_message_to_watchers") @patch("firefighter.raid.forms.SlackMessageRaidModifiedIssue") - def test_alert_slack_update_ticket(self, mock_message_class, mock_send_message): + def test_alert_slack_update_ticket(self, mock_message_class, mock_send_message, mock_send_to_channel): """Test alert_slack_update_ticket function.""" # Given mock_message = Mock() mock_message_class.return_value = mock_message mock_send_message.return_value = True + mock_send_to_channel.return_value = True # When result = alert_slack_update_ticket( @@ -544,6 +546,7 @@ def test_alert_slack_update_ticket(self, mock_message_class, mock_send_message): assert result is True mock_message_class.assert_called_once() mock_send_message.assert_called_once_with(jira_issue_id=10001, message=mock_message) + mock_send_to_channel.assert_called_once_with(10001, "Priority", mock_message) @pytest.mark.django_db diff --git a/tests/test_raid/test_slack_integration.py b/tests/test_raid/test_slack_integration.py new file mode 100644 index 00000000..0d06b0a9 --- /dev/null +++ b/tests/test_raid/test_slack_integration.py @@ -0,0 +1,293 @@ +"""Tests for enhanced Slack integration with Jira webhook updates.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from firefighter.incidents.factories import IncidentFactory, UserFactory +from firefighter.jira_app.models import JiraUser +from firefighter.raid.forms import ( + alert_slack_update_ticket, + send_message_to_incident_channel, +) +from firefighter.raid.messages import SlackMessageRaidModifiedIssue +from firefighter.raid.models import JiraTicket +from firefighter.raid.serializers import JiraWebhookUpdateSerializer + + +@pytest.mark.django_db +class TestSlackIncidentChannelIntegration: + """Test Slack incident channel integration for Jira updates.""" + + def setup_method(self): + """Set up test data.""" + self.incident = IncidentFactory() + self.user = UserFactory() + self.jira_user = JiraUser.objects.create(id="jira-user-slack", user=self.user) + self.jira_ticket = JiraTicket.objects.create( + id=88888, + key="INC-SLACK", + summary="Test Slack integration", + description="Test description", + reporter=self.jira_user, + incident=self.incident, + ) + + @patch("firefighter.raid.forms.JiraTicket.objects.select_related") + def test_send_message_to_incident_channel_critical_field_status(self, mock_select_related): + """Test that status changes are sent to incident channel.""" + message = SlackMessageRaidModifiedIssue( + jira_ticket_key="INC-SLACK", + jira_author_name="Test User", + jira_field_modified="status", + jira_field_from="Open", + jira_field_to="In Progress", + ) + + # Mock incident channel + mock_channel = MagicMock() + mock_channel.name = "incident-123" + + # Mock incident with channel + mock_incident = MagicMock() + mock_incident.id = 1 + mock_incident.incidentchannel = mock_channel + + # Mock jira ticket with mocked incident + mock_jira_ticket = MagicMock() + mock_jira_ticket.key = "INC-SLACK" + mock_jira_ticket.incident = mock_incident + + # Mock the ORM query chain + mock_manager = MagicMock() + mock_manager.get.return_value = mock_jira_ticket + mock_select_related.return_value = mock_manager + + result = send_message_to_incident_channel( + jira_ticket_id=88888, + jira_field_modified="status", + message=message + ) + + assert result is True + mock_channel.send_message_and_save.assert_called_once_with(message) + + @patch("firefighter.raid.forms.JiraTicket.objects.select_related") + def test_send_message_to_incident_channel_critical_field_priority(self, mock_select_related): + """Test that priority changes are sent to incident channel.""" + message = SlackMessageRaidModifiedIssue( + jira_ticket_key="INC-SLACK", + jira_author_name="Test User", + jira_field_modified="Priority", + jira_field_from="Medium", + jira_field_to="High", + ) + + # Mock incident channel + mock_channel = MagicMock() + mock_channel.name = "incident-123" + + # Mock incident with channel + mock_incident = MagicMock() + mock_incident.id = 1 + mock_incident.incidentchannel = mock_channel + + # Mock jira ticket with mocked incident + mock_jira_ticket = MagicMock() + mock_jira_ticket.key = "INC-SLACK" + mock_jira_ticket.incident = mock_incident + + # Mock the ORM query chain + mock_manager = MagicMock() + mock_manager.get.return_value = mock_jira_ticket + mock_select_related.return_value = mock_manager + + result = send_message_to_incident_channel( + jira_ticket_id=88888, + jira_field_modified="Priority", + message=message + ) + + assert result is True + mock_channel.send_message_and_save.assert_called_once_with(message) + + def test_send_message_to_incident_channel_non_critical_field_skipped(self): + """Test that non-critical field changes are not sent to incident channel.""" + message = SlackMessageRaidModifiedIssue( + jira_ticket_key="INC-SLACK", + jira_author_name="Test User", + jira_field_modified="description", + jira_field_from="Old description", + jira_field_to="New description", + ) + + # Mock incident channel + mock_channel = MagicMock() + self.incident.incidentchannel = mock_channel + + result = send_message_to_incident_channel( + jira_ticket_id=88888, + jira_field_modified="description", + message=message + ) + + assert result is True + mock_channel.send_message_and_save.assert_not_called() + + def test_send_message_to_incident_channel_no_incident(self): + """Test that tickets without incidents don't send to channel.""" + self.jira_ticket.incident = None + self.jira_ticket.save() + + message = SlackMessageRaidModifiedIssue( + jira_ticket_key="INC-SLACK", + jira_author_name="Test User", + jira_field_modified="status", + jira_field_from="Open", + jira_field_to="Closed", + ) + + result = send_message_to_incident_channel( + jira_ticket_id=88888, + jira_field_modified="status", + message=message + ) + + assert result is True # Success but no action taken + + def test_send_message_to_incident_channel_no_slack_channel(self): + """Test that incidents without Slack channels don't fail.""" + # Incident exists but has no incidentchannel attribute + message = SlackMessageRaidModifiedIssue( + jira_ticket_key="INC-SLACK", + jira_author_name="Test User", + jira_field_modified="status", + jira_field_from="Open", + jira_field_to="Closed", + ) + + result = send_message_to_incident_channel( + jira_ticket_id=88888, + jira_field_modified="status", + message=message + ) + + assert result is True # Success but no action taken + + def test_send_message_to_incident_channel_ticket_not_found(self): + """Test that non-existent tickets return False.""" + message = SlackMessageRaidModifiedIssue( + jira_ticket_key="NONEXISTENT", + jira_author_name="Test User", + jira_field_modified="status", + jira_field_from="Open", + jira_field_to="Closed", + ) + + result = send_message_to_incident_channel( + jira_ticket_id=99999, # Non-existent ID + jira_field_modified="status", + message=message + ) + + assert result is False + + @patch("firefighter.raid.forms.send_message_to_incident_channel") + @patch("firefighter.raid.forms.send_message_to_watchers") + def test_enhanced_alert_slack_update_ticket_integration( + self, mock_send_to_watchers, mock_send_to_channel + ): + """Test that the enhanced alert function calls both notification methods.""" + mock_send_to_watchers.return_value = True + mock_send_to_channel.return_value = True + + result = alert_slack_update_ticket( + jira_ticket_id=88888, + jira_ticket_key="INC-SLACK", + jira_author_name="Test User", + jira_field_modified="status", + jira_field_from="Open", + jira_field_to="In Progress", + ) + + assert result is True + mock_send_to_watchers.assert_called_once() + mock_send_to_channel.assert_called_once() + + @patch("firefighter.raid.serializers.handle_jira_webhook_update") + @patch("firefighter.raid.forms.send_message_to_incident_channel") + @patch("firefighter.raid.forms.send_message_to_watchers") + def test_webhook_serializer_uses_enhanced_slack_integration( + self, mock_send_to_watchers, mock_send_to_channel, mock_handle_webhook + ): + """Test that webhook serializer uses the enhanced Slack integration.""" + mock_handle_webhook.return_value = True + mock_send_to_watchers.return_value = True + mock_send_to_channel.return_value = True + + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": {"id": "88888", "key": "INC-SLACK", "fields": {}}, + "changelog": { + "items": [ + { + "field": "status", + "fromString": "Open", + "toString": "In Progress", + } + ] + }, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:issue_updated", + } + ) + + assert serializer.is_valid() + result = serializer.save() + + assert result is True + mock_handle_webhook.assert_called_once() + mock_send_to_watchers.assert_called_once() + mock_send_to_channel.assert_called_once() + + @patch("firefighter.raid.forms.JiraTicket.objects.select_related") + def test_send_message_channel_error_handling(self, mock_select_related): + """Test that channel send errors are handled gracefully.""" + message = SlackMessageRaidModifiedIssue( + jira_ticket_key="INC-SLACK", + jira_author_name="Test User", + jira_field_modified="Priority", + jira_field_from="Low", + jira_field_to="High", + ) + + # Mock incident channel that raises an exception + mock_channel = MagicMock() + mock_channel.name = "incident-123" + mock_channel.send_message_and_save.side_effect = Exception("Slack API error") + + # Mock incident with channel + mock_incident = MagicMock() + mock_incident.id = 1 + mock_incident.incidentchannel = mock_channel + + # Mock jira ticket with mocked incident + mock_jira_ticket = MagicMock() + mock_jira_ticket.key = "INC-SLACK" + mock_jira_ticket.incident = mock_incident + + # Mock the ORM query chain + mock_manager = MagicMock() + mock_manager.get.return_value = mock_jira_ticket + mock_select_related.return_value = mock_manager + + result = send_message_to_incident_channel( + jira_ticket_id=88888, + jira_field_modified="Priority", + message=message + ) + + assert result is False + mock_channel.send_message_and_save.assert_called_once_with(message) diff --git a/tests/test_raid/test_sync.py b/tests/test_raid/test_sync.py new file mode 100644 index 00000000..47916c4c --- /dev/null +++ b/tests/test_raid/test_sync.py @@ -0,0 +1,426 @@ +"""Tests for bidirectional synchronization between Impact, Jira and Slack.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from django.core.cache import cache + +from firefighter.incidents.enums import IncidentStatus +from firefighter.incidents.factories import ( + EnvironmentFactory, + GroupFactory, + IncidentCategoryFactory, + IncidentFactory, + UserFactory, +) +from firefighter.incidents.models import ( + Environment, + Incident, + IncidentCategory, + IncidentRole, + IncidentRoleType, + IncidentUpdate, + Priority, +) +from firefighter.jira_app.models import JiraUser +from firefighter.raid.models import JiraTicket +from firefighter.raid.sync import ( + IMPACT_TO_JIRA_STATUS_MAP, + JIRA_TO_IMPACT_PRIORITY_MAP, + JIRA_TO_IMPACT_STATUS_MAP, + SyncDirection, + handle_jira_webhook_update, + should_skip_sync, + sync_incident_to_jira, + sync_jira_fields_to_incident, + sync_jira_priority_to_incident, + sync_jira_status_to_incident, +) + + +@pytest.mark.django_db +class TestSyncLoopPrevention: + """Test sync loop prevention mechanism.""" + + def test_should_skip_sync_first_call(self): + """Test that first sync call is not skipped.""" + cache.clear() + result = should_skip_sync("incident", "123", SyncDirection.IMPACT_TO_JIRA) + assert result is False + + def test_should_skip_sync_second_call(self): + """Test that immediate second sync call is skipped.""" + cache.clear() + # First call + should_skip_sync("incident", "456", SyncDirection.JIRA_TO_IMPACT) + # Second call within cache timeout + result = should_skip_sync("incident", "456", SyncDirection.JIRA_TO_IMPACT) + assert result is True + + def test_should_skip_sync_different_direction(self): + """Test that different sync directions are not skipped.""" + cache.clear() + should_skip_sync("incident", "789", SyncDirection.IMPACT_TO_JIRA) + # Different direction should not be skipped + result = should_skip_sync("incident", "789", SyncDirection.JIRA_TO_IMPACT) + assert result is False + + def test_should_skip_sync_different_entity(self): + """Test that different entities are tracked separately.""" + cache.clear() + should_skip_sync("incident", "111", SyncDirection.IMPACT_TO_JIRA) + # Different entity should not be skipped + result = should_skip_sync("jira_ticket", "111", SyncDirection.IMPACT_TO_JIRA) + assert result is False + + +@pytest.mark.django_db +class TestJiraToImpactSync: + """Test syncing from Jira to Impact.""" + + def setup_method(self): + """Set up test data.""" + cache.clear() + # Clear test-specific data only, not reference data like priorities + JiraTicket.objects.all().delete() + Incident.objects.all().delete() + + # Create required objects for IncidentFactory + # Ensure we have the required related objects + if not Environment.objects.exists(): + EnvironmentFactory() + if not IncidentCategory.objects.exists(): + IncidentCategoryFactory() + + # Create commander role type for testing + self.commander_role_type, _ = IncidentRoleType.objects.get_or_create( + slug="commander", + defaults={ + "name": "Commander", + "description": "Incident Commander", + "order": 1, + "group": GroupFactory(), + }, + ) + + # Get or create a priority for IncidentFactory + self.priority, _ = Priority.objects.get_or_create(value=3, defaults={"name": "Medium Test Priority"}) + self.incident = IncidentFactory(status=IncidentStatus.INVESTIGATING, priority=self.priority) + self.user = UserFactory() + self.jira_user = JiraUser.objects.create(id="jira-user-123", user=self.user) + self.jira_ticket = JiraTicket.objects.create( + id=12345, + key="INC-123", + summary="Test ticket", + description="Test description", + reporter=self.jira_user, + incident=self.incident, + ) + + def test_sync_jira_status_to_incident_success(self): + """Test successful status sync from Jira to Impact.""" + result = sync_jira_status_to_incident(self.jira_ticket, "Resolved") + + assert result is True + self.incident.refresh_from_db() + assert self.incident.status == IncidentStatus.FIXED + + def test_sync_jira_status_to_incident_no_incident(self): + """Test status sync when Jira ticket has no linked incident.""" + self.jira_ticket.incident = None + self.jira_ticket.save() + + result = sync_jira_status_to_incident(self.jira_ticket, "Resolved") + assert result is False + + def test_sync_jira_status_to_incident_unknown_status(self): + """Test status sync with unknown Jira status.""" + result = sync_jira_status_to_incident(self.jira_ticket, "Unknown Status") + assert result is False + # Status should remain unchanged + self.incident.refresh_from_db() + assert self.incident.status == IncidentStatus.INVESTIGATING + + def test_sync_jira_status_to_incident_no_change(self): + """Test status sync when status is already the same.""" + self.incident.status = IncidentStatus.FIXED + self.incident.save() + + result = sync_jira_status_to_incident(self.jira_ticket, "Resolved") + assert result is True # Success but no actual update + + def test_sync_jira_status_to_incident_creates_update(self): + """Test that status sync creates an IncidentUpdate record.""" + initial_count = IncidentUpdate.objects.filter(incident=self.incident).count() + + sync_jira_status_to_incident(self.jira_ticket, "Closed") + + # Check that an IncidentUpdate was created + new_count = IncidentUpdate.objects.filter(incident=self.incident).count() + assert new_count == initial_count + 1 + + latest_update = IncidentUpdate.objects.filter(incident=self.incident).latest( + "created_at" + ) + assert latest_update.status == IncidentStatus.POST_MORTEM + assert "from Jira" in latest_update.message + + @patch("firefighter.raid.sync.jira_client") + def test_sync_jira_priority_to_incident_success(self, mock_jira_client): # noqa: ARG002 + """Test successful priority sync from Jira to Impact.""" + priority_p2, _ = Priority.objects.get_or_create(value=2, defaults={"name": "High Priority"}) + + result = sync_jira_priority_to_incident(self.jira_ticket, "High") + + assert result is True + self.incident.refresh_from_db() + assert self.incident.priority == priority_p2 + + def test_sync_jira_priority_to_incident_unknown_priority(self): + """Test priority sync with unknown Jira priority.""" + result = sync_jira_priority_to_incident(self.jira_ticket, "Unknown") + assert result is False + + @patch("firefighter.raid.sync.jira_client") + def test_sync_jira_fields_to_incident(self, mock_jira_client): + """Test syncing multiple fields from Jira to Impact.""" + # Mock the jira_client to avoid real JIRA calls + mock_jira_client.get_jira_user_from_user.return_value = self.jira_user + + jira_fields = { + "summary": "New title", + "description": "New description", + "assignee": {"accountId": self.jira_user.id}, + } + + result = sync_jira_fields_to_incident(self.jira_ticket, jira_fields) + + assert result is True + self.incident.refresh_from_db() + assert self.incident.title == "New title" + assert self.incident.description == "New description" + + # Check that commander role was created + commander_role = IncidentRole.objects.filter( + incident=self.incident, role_type=self.commander_role_type + ).first() + assert commander_role is not None + assert commander_role.user == self.user + + def test_handle_jira_webhook_update_status_change(self): + """Test handling Jira webhook with status change.""" + issue_data = {"key": "INC-123", "fields": {}} + changelog_data = { + "items": [{"field": "status", "toString": "In Progress"}] + } + + with patch( + "firefighter.raid.sync.sync_jira_status_to_incident", return_value=True + ) as mock_sync: + result = handle_jira_webhook_update(issue_data, changelog_data) + + assert result is True + mock_sync.assert_called_once_with(self.jira_ticket, "In Progress") + + def test_handle_jira_webhook_update_priority_change(self): + """Test handling Jira webhook with priority change.""" + issue_data = {"key": "INC-123", "fields": {}} + changelog_data = {"items": [{"field": "priority", "toString": "High"}]} + + with patch( + "firefighter.raid.sync.sync_jira_priority_to_incident", return_value=True + ) as mock_sync: + result = handle_jira_webhook_update(issue_data, changelog_data) + + assert result is True + mock_sync.assert_called_once_with(self.jira_ticket, "High") + + def test_handle_jira_webhook_update_no_ticket(self): + """Test handling webhook when Jira ticket doesn't exist.""" + issue_data = {"key": "UNKNOWN-999", "fields": {}} + changelog_data = {"items": [{"field": "status", "toString": "Resolved"}]} + + result = handle_jira_webhook_update(issue_data, changelog_data) + assert result is False + + +@pytest.mark.django_db +class TestImpactToJiraSync: + """Test syncing from Impact to Jira.""" + + def setup_method(self): + """Set up test data.""" + cache.clear() + # Clear test-specific data only, not reference data like priorities + JiraTicket.objects.all().delete() + Incident.objects.all().delete() + + # Create required objects for IncidentFactory + # Ensure we have the required related objects + if not Environment.objects.exists(): + EnvironmentFactory() + if not IncidentCategory.objects.exists(): + IncidentCategoryFactory() + + # Create commander role type for testing + self.commander_role_type, _ = IncidentRoleType.objects.get_or_create( + slug="commander", + defaults={ + "name": "Commander", + "description": "Incident Commander", + "order": 1, + "group": GroupFactory(), + }, + ) + + # Get or create a priority for IncidentFactory + self.priority, _ = Priority.objects.get_or_create(value=2, defaults={"name": "High Test Priority"}) + self.incident = IncidentFactory( + title="Original title", + description="Original description", + status=IncidentStatus.INVESTIGATING, + priority=self.priority, + ) + self.user = UserFactory() + self.jira_user = JiraUser.objects.create(id="jira-user-456", user=self.user) + self.jira_ticket = JiraTicket.objects.create( + id=67890, + key="INC-456", + summary="Test ticket", + description="Test description", + reporter=self.jira_user, + incident=self.incident, + ) + + @patch("firefighter.raid.sync.jira_client") + def test_sync_incident_to_jira_title_change(self, mock_jira_client): + """Test syncing title change from Impact to Jira.""" + mock_jira_client.update_issue.return_value = True + + result = sync_incident_to_jira(self.incident, ["title"]) + + assert result is True + mock_jira_client.update_issue.assert_called_once_with( + "INC-456", {"summary": "Original title"} + ) + + @patch("firefighter.raid.sync.jira_client") + def test_sync_incident_to_jira_description_change(self, mock_jira_client): + """Test syncing description change from Impact to Jira.""" + mock_jira_client.update_issue.return_value = True + + result = sync_incident_to_jira(self.incident, ["description"]) + + assert result is True + mock_jira_client.update_issue.assert_called_once_with( + "INC-456", {"description": "Original description"} + ) + + @patch("firefighter.raid.sync.jira_client") + def test_sync_incident_to_jira_priority_change(self, mock_jira_client): + """Test syncing priority change from Impact to Jira.""" + mock_jira_client.update_issue.return_value = True + + result = sync_incident_to_jira(self.incident, ["priority"]) + + assert result is True + mock_jira_client.update_issue.assert_called_once_with( + "INC-456", {"priority": {"name": "High"}} + ) + + @patch("firefighter.raid.sync.jira_client") + def test_sync_incident_to_jira_status_change(self, mock_jira_client): + """Test syncing status change from Impact to Jira.""" + mock_jira_client.transition_issue.return_value = True + + result = sync_incident_to_jira(self.incident, ["status"]) + + assert result is True + mock_jira_client.transition_issue.assert_called_once_with( + "INC-456", "In Progress" + ) + + @patch("firefighter.raid.sync.jira_client") + def test_sync_incident_to_jira_commander_change(self, mock_jira_client): + """Test syncing commander change from Impact to Jira.""" + # Create the commander role for the incident + IncidentRole.objects.create( + incident=self.incident, + user=self.user, + role_type=self.commander_role_type, + ) + + mock_jira_client.get_jira_user_from_user.return_value = self.jira_user + mock_jira_client.update_issue.return_value = True + + result = sync_incident_to_jira(self.incident, ["commander"]) + + assert result is True + mock_jira_client.update_issue.assert_called_once_with( + "INC-456", {"assignee": {"accountId": "jira-user-456"}} + ) + + @patch("firefighter.raid.sync.jira_client") + def test_sync_incident_to_jira_multiple_fields(self, mock_jira_client): + """Test syncing multiple fields from Impact to Jira.""" + mock_jira_client.update_issue.return_value = True + + result = sync_incident_to_jira(self.incident, ["title", "description", "priority"]) + + assert result is True + expected_fields = { + "summary": "Original title", + "description": "Original description", + "priority": {"name": "High"}, + } + mock_jira_client.update_issue.assert_called_once_with("INC-456", expected_fields) + + def test_sync_incident_to_jira_no_ticket(self): + """Test syncing when incident has no linked Jira ticket.""" + incident_without_ticket = IncidentFactory() + + result = sync_incident_to_jira(incident_without_ticket, ["title"]) + assert result is False + + @patch("firefighter.raid.sync.jira_client") + def test_sync_incident_to_jira_with_sync_loop_prevention(self, mock_jira_client): + """Test that sync loop prevention works.""" + # First sync should succeed + mock_jira_client.update_issue.return_value = True + result1 = sync_incident_to_jira(self.incident, ["title"]) + assert result1 is True + + # Second immediate sync should be skipped + result2 = sync_incident_to_jira(self.incident, ["title"]) + assert result2 is False + + +@pytest.mark.django_db +class TestStatusMapping: + """Test status mapping between systems.""" + + def test_jira_to_impact_status_mapping_completeness(self): + """Test that all common Jira statuses are mapped.""" + common_jira_statuses = [ + "Open", + "In Progress", + "Resolved", + "Closed", + "Reopened", + ] + for status in common_jira_statuses: + assert status in JIRA_TO_IMPACT_STATUS_MAP + + def test_impact_to_jira_status_mapping_completeness(self): + """Test that all Impact statuses are mapped.""" + for status in IncidentStatus: + assert status in IMPACT_TO_JIRA_STATUS_MAP + + def test_priority_mapping_completeness(self): + """Test that all common Jira priorities are mapped.""" + common_jira_priorities = ["Highest", "High", "Medium", "Low", "Lowest"] + for priority in common_jira_priorities: + assert priority in JIRA_TO_IMPACT_PRIORITY_MAP + assert 1 <= JIRA_TO_IMPACT_PRIORITY_MAP[priority] <= 5 diff --git a/tests/test_raid/test_sync_signals.py b/tests/test_raid/test_sync_signals.py new file mode 100644 index 00000000..54f09f20 --- /dev/null +++ b/tests/test_raid/test_sync_signals.py @@ -0,0 +1,244 @@ +"""Tests for synchronization signal handlers.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from django.test import override_settings + +from firefighter.incidents.enums import IncidentStatus +from firefighter.incidents.factories import ( + EnvironmentFactory, + IncidentCategoryFactory, + IncidentFactory, + IncidentUpdateFactory, + PriorityFactory, + UserFactory, +) +from firefighter.incidents.models import ( + Environment, + Incident, + IncidentCategory, + Priority, +) +from firefighter.jira_app.models import JiraUser +from firefighter.raid.models import JiraTicket +from firefighter.raid.signals.incident_updated_sync import ( + sync_incident_changes_to_jira, +) + + +@pytest.mark.django_db +class TestIncidentSyncSignals: + """Test incident synchronization signal handlers.""" + + def setup_method(self): + """Set up test data.""" + # Clear existing priorities to avoid conflicts + Priority.objects.all().delete() + + # Ensure we have the required related objects + if not Environment.objects.exists(): + EnvironmentFactory() + if not IncidentCategory.objects.exists(): + IncidentCategoryFactory() + + self.priority = PriorityFactory(value=3) + self.incident = IncidentFactory( + title="Test incident", + description="Test description", + status=IncidentStatus.INVESTIGATING, + priority=self.priority, + ) + self.user = UserFactory() + self.jira_user = JiraUser.objects.create(id="jira-user-789", user=self.user) + self.jira_ticket = JiraTicket.objects.create( + id=11111, + key="INC-789", + summary="Test ticket", + description="Test description", + reporter=self.jira_user, + incident=self.incident, + ) + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_changes_to_jira_on_update(self, mock_sync): + """Test that incident changes trigger Jira sync.""" + mock_sync.return_value = True + + # Simulate an incident save with specific fields updated + sync_incident_changes_to_jira( + sender=Incident, + instance=self.incident, + created=False, + update_fields=["title", "status"], + ) + + mock_sync.assert_called_once() + call_args = mock_sync.call_args[0] + assert call_args[0] == self.incident + assert set(call_args[1]) == {"title", "status"} + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_changes_skipped_for_new_incident(self, mock_sync): + """Test that new incidents don't trigger update sync.""" + sync_incident_changes_to_jira( + sender=Incident, + instance=self.incident, + created=True, + update_fields=["title"], + ) + + mock_sync.assert_not_called() + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_changes_skipped_for_non_sync_fields(self, mock_sync): + """Test that non-syncable field updates are skipped.""" + sync_incident_changes_to_jira( + sender=Incident, + instance=self.incident, + created=False, + update_fields=["updated_at", "created_at"], + ) + + mock_sync.assert_not_called() + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_changes_filters_sync_fields(self, mock_sync): + """Test that only syncable fields are passed to sync function.""" + mock_sync.return_value = True + + sync_incident_changes_to_jira( + sender=Incident, + instance=self.incident, + created=False, + update_fields=["title", "updated_at", "priority", "created_at"], + ) + + # Should only sync title and priority, not timestamp fields + mock_sync.assert_called_once() + call_args = mock_sync.call_args[0] + assert call_args[0] == self.incident + assert set(call_args[1]) == {"title", "priority"} + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=False) + def test_sync_incident_changes_skipped_when_raid_disabled(self, mock_sync): + """Test that sync is skipped when RAID is disabled.""" + sync_incident_changes_to_jira( + sender=Incident, + instance=self.incident, + created=False, + update_fields=["title"], + ) + + mock_sync.assert_not_called() + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_update_to_jira_title_change(self, mock_sync): + """Test that IncidentUpdate with title change triggers sync.""" + mock_sync.return_value = True + + # Creating the IncidentUpdate will automatically trigger the signal + IncidentUpdateFactory( + incident=self.incident, + title="New title", + created_by=self.user, + ) + + # The signal should have been called automatically + mock_sync.assert_called_once_with(self.incident, ["title"]) + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_update_to_jira_status_change(self, mock_sync): + """Test that IncidentUpdate with status change triggers sync.""" + mock_sync.return_value = True + + IncidentUpdateFactory( + incident=self.incident, + _status=IncidentStatus.FIXING, + created_by=self.user, + ) + + mock_sync.assert_called_once_with(self.incident, ["status"]) + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_update_to_jira_priority_change(self, mock_sync): + """Test that IncidentUpdate with priority change triggers sync.""" + mock_sync.return_value = True + new_priority, _ = Priority.objects.get_or_create(value=1, defaults={"name": "Critical Priority"}) + + IncidentUpdateFactory( + incident=self.incident, + priority=new_priority, + created_by=self.user, + ) + + mock_sync.assert_called_once_with(self.incident, ["priority"]) + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_update_skipped_for_jira_origin(self, mock_sync): + """Test that updates from Jira sync are not synced back.""" + IncidentUpdateFactory( + incident=self.incident, + title="New title", + created_by=None, # System update + message="Title updated from Jira: New title", + ) + + mock_sync.assert_not_called() + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_update_multiple_fields(self, mock_sync): + """Test that IncidentUpdate with multiple changes triggers sync.""" + mock_sync.return_value = True + + IncidentUpdateFactory( + incident=self.incident, + title="New title", + description="New description", + _status=IncidentStatus.FIXED, + created_by=self.user, + ) + + mock_sync.assert_called_once() + call_args = mock_sync.call_args[0] + assert call_args[0] == self.incident + assert set(call_args[1]) == {"title", "description", "status"} + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=True) + def test_sync_incident_update_handles_sync_error(self, mock_sync): + """Test that sync errors are handled gracefully.""" + mock_sync.side_effect = Exception("Sync failed") + + # Should not raise exception even if sync fails + IncidentUpdateFactory( + incident=self.incident, + title="New title", + created_by=self.user, + ) + + mock_sync.assert_called_once() + + @patch("firefighter.raid.signals.incident_updated_sync.sync_incident_to_jira") + @override_settings(ENABLE_RAID=False) + def test_sync_incident_update_skipped_when_raid_disabled(self, mock_sync): + """Test that IncidentUpdate sync is skipped when RAID is disabled.""" + IncidentUpdateFactory( + incident=self.incident, + title="New title", + created_by=self.user, + ) + + mock_sync.assert_not_called() diff --git a/tests/test_raid/test_webhook_serializers.py b/tests/test_raid/test_webhook_serializers.py new file mode 100644 index 00000000..17dcc703 --- /dev/null +++ b/tests/test_raid/test_webhook_serializers.py @@ -0,0 +1,257 @@ +"""Tests for Jira webhook serializers with sync functionality.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from firefighter.incidents.factories import IncidentFactory, UserFactory +from firefighter.jira_app.client import SlackNotificationError +from firefighter.jira_app.models import JiraUser +from firefighter.raid.models import JiraTicket +from firefighter.raid.serializers import JiraWebhookUpdateSerializer + + +@pytest.mark.django_db +class TestJiraWebhookUpdateSerializerWithSync: + """Test JiraWebhookUpdateSerializer with sync functionality.""" + + def setup_method(self): + """Set up test data.""" + self.incident = IncidentFactory() + self.user = UserFactory() + self.jira_user = JiraUser.objects.create(id="jira-user-999", user=self.user) + self.jira_ticket = JiraTicket.objects.create( + id=99999, + key="INC-999", + summary="Test ticket", + description="Test description", + reporter=self.jira_user, + incident=self.incident, + ) + + @patch("firefighter.raid.serializers.handle_jira_webhook_update") + @patch("firefighter.raid.serializers.alert_slack_update_ticket") + def test_webhook_update_triggers_sync( + self, mock_slack_alert, mock_handle_webhook + ): + """Test that webhook update triggers sync to Impact.""" + mock_handle_webhook.return_value = True + mock_slack_alert.return_value = True + + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": {"id": "99999", "key": "INC-999", "fields": {}}, + "changelog": { + "items": [ + { + "field": "status", + "fromString": "Open", + "toString": "In Progress", + } + ] + }, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:issue_updated", + } + ) + + assert serializer.is_valid() + result = serializer.save() + + assert result is True + mock_handle_webhook.assert_called_once() + mock_slack_alert.assert_called_once() + + @patch("firefighter.raid.serializers.handle_jira_webhook_update") + @patch("firefighter.raid.serializers.alert_slack_update_ticket") + def test_webhook_update_sync_failure_still_alerts_slack( + self, mock_slack_alert, mock_handle_webhook + ): + """Test that sync failure doesn't prevent Slack alert.""" + mock_handle_webhook.return_value = False # Sync fails + mock_slack_alert.return_value = True + + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": {"id": "99999", "key": "INC-999", "fields": {}}, + "changelog": { + "items": [ + { + "field": "Priority", + "fromString": "Medium", + "toString": "High", + } + ] + }, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:issue_updated", + } + ) + + assert serializer.is_valid() + result = serializer.save() + + assert result is True + mock_handle_webhook.assert_called_once() + mock_slack_alert.assert_called_once() + + @patch("firefighter.raid.serializers.handle_jira_webhook_update") + @patch("firefighter.raid.serializers.alert_slack_update_ticket") + def test_webhook_update_non_tracked_field_no_slack( + self, mock_slack_alert, mock_handle_webhook + ): + """Test that non-tracked field changes still sync but don't alert Slack.""" + mock_handle_webhook.return_value = True + + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": {"id": "99999", "key": "INC-999", "fields": {}}, + "changelog": { + "items": [ + { + "field": "labels", # Not in tracked fields for Slack + "fromString": "label1", + "toString": "label2", + } + ] + }, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:issue_updated", + } + ) + + assert serializer.is_valid() + result = serializer.save() + + assert result is True + mock_handle_webhook.assert_called_once() + mock_slack_alert.assert_not_called() + + @patch("firefighter.raid.serializers.handle_jira_webhook_update") + @patch("firefighter.raid.serializers.alert_slack_update_ticket") + def test_webhook_update_slack_alert_failure_raises_error( + self, mock_slack_alert, mock_handle_webhook + ): + """Test that Slack alert failure raises an error.""" + mock_handle_webhook.return_value = True + mock_slack_alert.return_value = False # Slack alert fails + + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": {"id": "99999", "key": "INC-999", "fields": {}}, + "changelog": { + "items": [ + { + "field": "status", + "fromString": "Open", + "toString": "Closed", + } + ] + }, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:issue_updated", + } + ) + + assert serializer.is_valid() + + with pytest.raises(SlackNotificationError): + serializer.save() + + def test_webhook_update_invalid_event(self): + """Test that invalid webhook event is rejected.""" + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": {"id": "99999", "key": "INC-999", "fields": {}}, + "changelog": {"items": []}, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:invalid_event", + } + ) + + assert not serializer.is_valid() + assert "webhookEvent" in serializer.errors + + @patch("firefighter.raid.serializers.handle_jira_webhook_update") + @patch("firefighter.raid.serializers.alert_slack_update_ticket") + def test_webhook_update_multiple_field_changes( + self, mock_slack_alert, mock_handle_webhook + ): + """Test webhook with multiple field changes.""" + mock_handle_webhook.return_value = True + mock_slack_alert.return_value = True + + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": { + "id": "99999", + "key": "INC-999", + "fields": { + "summary": "Updated title", + "description": "Updated description", + "status": {"name": "In Progress"}, + }, + }, + "changelog": { + "items": [ + { + "field": "status", + "fromString": "Open", + "toString": "In Progress", + }, + { + "field": "summary", + "fromString": "Old title", + "toString": "Updated title", + }, + ] + }, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:issue_updated", + } + ) + + assert serializer.is_valid() + result = serializer.save() + + assert result is True + # handle_jira_webhook_update should be called once with all changes + mock_handle_webhook.assert_called_once() + # alert_slack_update_ticket should be called for the first tracked field + mock_slack_alert.assert_called_once() + + @patch("firefighter.raid.serializers.logger") + @patch("firefighter.raid.serializers.handle_jira_webhook_update") + @patch("firefighter.raid.serializers.alert_slack_update_ticket") + def test_webhook_update_logs_sync_failure( + self, mock_slack_alert, mock_handle_webhook, mock_logger + ): + """Test that sync failures are logged.""" + mock_handle_webhook.return_value = False + mock_slack_alert.return_value = True + + serializer = JiraWebhookUpdateSerializer( + data={ + "issue": {"id": "99999", "key": "INC-999", "fields": {}}, + "changelog": { + "items": [ + { + "field": "Priority", + "fromString": "Low", + "toString": "High", + } + ] + }, + "user": {"displayName": "Test User"}, + "webhookEvent": "jira:issue_updated", + } + ) + + assert serializer.is_valid() + serializer.save() + + mock_logger.warning.assert_called_once_with( + "Failed to sync Jira changes to Impact incident" + )