Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/firefighter/incidents/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Group,
Incident,
IncidentCategory,
IncidentUpdate,
Priority,
User,
)
Expand Down Expand Up @@ -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]
4 changes: 4 additions & 0 deletions src/firefighter/incidents/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 60 additions & 1 deletion src/firefighter/raid/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down
69 changes: 68 additions & 1 deletion src/firefighter/raid/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions src/firefighter/raid/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/firefighter/raid/signals/__init__.py
Original file line number Diff line number Diff line change
@@ -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
137 changes: 137 additions & 0 deletions src/firefighter/raid/signals/incident_updated_sync.py
Original file line number Diff line number Diff line change
@@ -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")
Loading