diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index ebdfab34..0fd9d0d0 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -18,6 +18,7 @@ Tag, TagType, ) +from .services import sync_incident_to_slack @dataclass @@ -559,6 +560,12 @@ def update(self, instance: Incident, validated_data: dict) -> Incident: ) instance.impact_type_tags.set(tags) + # Only sync to Slack if title, severity, or captain changed + topic_fields = {"title", "severity", "captain"} + sync_relevant_fields = topic_fields.intersection(validated_data.keys()) + if sync_relevant_fields: + sync_incident_to_slack(instance) + return instance diff --git a/src/firetower/incidents/services.py b/src/firetower/incidents/services.py index 378968ab..e8b7b888 100644 --- a/src/firetower/incidents/services.py +++ b/src/firetower/incidents/services.py @@ -5,6 +5,7 @@ from django.conf import settings from django.utils import timezone +from firetower.auth.models import ExternalProfileType 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 @@ -114,3 +115,46 @@ def sync_incident_participants_from_slack( ) return stats + + +def sync_incident_to_slack(incident: Incident) -> None: + """ + Sync incident changes to Slack channel topic. + + Updates the Slack channel topic with the incident's title, severity, and captain. + """ + slack_link = incident.external_links.filter(type=ExternalLinkType.SLACK).first() + + if not slack_link: + logger.warning(f"No Slack link found for incident {incident.id}") + return + + channel_id = _slack_service.parse_channel_id_from_url(slack_link.url) + + if not channel_id: + logger.warning(f"Could not parse channel ID from URL: {slack_link.url}") + return + + if incident.captain: + slack_profile = incident.captain.external_profiles.filter( + type=ExternalProfileType.SLACK + ).first() + if slack_profile: + captain_display = f"<@{slack_profile.external_id}>" + else: + captain_display = incident.captain.get_full_name() + else: + captain_display = "None" + + # Slack topic limit is 250 chars, truncate title to fit + prefix = f"[{incident.severity}] {incident.incident_number} " + suffix = f" | IC: {captain_display}" + max_title_len = 250 - len(prefix) - len(suffix) + title = incident.title[:max_title_len] + + topic = f"{prefix}{title}{suffix}" + + if _slack_service.update_channel_topic(channel_id, topic): + logger.info(f"Successfully updated topic for incident {incident.id}") + else: + logger.error(f"Failed to update topic for incident {incident.id}") diff --git a/src/firetower/incidents/tests/test_serializers.py b/src/firetower/incidents/tests/test_serializers.py index b4be67ba..6cd3677e 100644 --- a/src/firetower/incidents/tests/test_serializers.py +++ b/src/firetower/incidents/tests/test_serializers.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from django.conf import settings from django.contrib.auth.models import User @@ -14,6 +16,7 @@ from firetower.incidents.serializers import ( IncidentDetailUISerializer, IncidentListUISerializer, + IncidentWriteSerializer, ) @@ -143,3 +146,87 @@ def test_incident_detail_serialization(self): ) assert "jira" not in data["external_links"] # Not set, so not included assert len(data["external_links"]) == 1 + + +@pytest.mark.django_db +class TestIncidentWriteSerializerSlackSync: + @pytest.fixture + def incident(self): + captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Jane", + last_name="Captain", + ) + reporter = User.objects.create_user( + username="reporter@example.com", + email="reporter@example.com", + first_name="John", + last_name="Reporter", + ) + return Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + captain=captain, + reporter=reporter, + ) + + @pytest.fixture + def new_captain(self): + return User.objects.create_user( + username="newcaptain@example.com", + email="newcaptain@example.com", + first_name="New", + last_name="Captain", + ) + + @patch("firetower.incidents.serializers.sync_incident_to_slack") + def test_syncs_on_title_change(self, mock_sync, incident): + serializer = IncidentWriteSerializer( + incident, data={"title": "New Title"}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + mock_sync.assert_called_once_with(incident) + + @patch("firetower.incidents.serializers.sync_incident_to_slack") + def test_syncs_on_severity_change(self, mock_sync, incident): + serializer = IncidentWriteSerializer( + incident, data={"severity": IncidentSeverity.P2}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + mock_sync.assert_called_once_with(incident) + + @patch("firetower.incidents.serializers.sync_incident_to_slack") + def test_syncs_on_captain_change(self, mock_sync, incident, new_captain): + serializer = IncidentWriteSerializer( + incident, data={"captain": new_captain.email}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + mock_sync.assert_called_once_with(incident) + + @patch("firetower.incidents.serializers.sync_incident_to_slack") + def test_does_not_sync_on_description_change(self, mock_sync, incident): + serializer = IncidentWriteSerializer( + incident, data={"description": "Updated description"}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + mock_sync.assert_not_called() + + @patch("firetower.incidents.serializers.sync_incident_to_slack") + def test_does_not_sync_on_status_change(self, mock_sync, incident): + serializer = IncidentWriteSerializer( + incident, data={"status": IncidentStatus.MITIGATED}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + mock_sync.assert_not_called() diff --git a/src/firetower/incidents/tests/test_services.py b/src/firetower/incidents/tests/test_services.py index 34f50114..6cb7127a 100644 --- a/src/firetower/incidents/tests/test_services.py +++ b/src/firetower/incidents/tests/test_services.py @@ -13,7 +13,10 @@ IncidentSeverity, IncidentStatus, ) -from firetower.incidents.services import sync_incident_participants_from_slack +from firetower.incidents.services import ( + sync_incident_participants_from_slack, + sync_incident_to_slack, +) @pytest.mark.django_db @@ -359,3 +362,142 @@ def test_skips_bots(self): mock_get_user.assert_called_once_with("U11111") assert stats.added == 1 assert stats.errors == [] + + +@pytest.mark.django_db +class TestSyncIncidentToSlack: + def test_syncs_topic_to_slack_channel_with_slack_profile(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Captain", + last_name="One", + ) + ExternalProfile.objects.create( + user=captain, + type=ExternalProfileType.SLACK, + external_id="U99999", + ) + incident.captain = captain + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://workspace.slack.com/archives/C12345", + ) + + with patch( + "firetower.incidents.services._slack_service.update_channel_topic" + ) as mock_update: + mock_update.return_value = True + sync_incident_to_slack(incident) + + mock_update.assert_called_once_with( + "C12345", + f"[P1] {incident.incident_number} Test Incident | IC: <@U99999>", + ) + + def test_syncs_topic_falls_back_to_full_name_without_slack_profile(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Captain", + last_name="One", + ) + incident.captain = captain + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://workspace.slack.com/archives/C12345", + ) + + with patch( + "firetower.incidents.services._slack_service.update_channel_topic" + ) as mock_update: + mock_update.return_value = True + sync_incident_to_slack(incident) + + mock_update.assert_called_once_with( + "C12345", + f"[P1] {incident.incident_number} Test Incident | IC: Captain One", + ) + + def test_skips_if_no_slack_link(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Captain", + last_name="One", + ) + incident.captain = captain + + with patch( + "firetower.incidents.services._slack_service.update_channel_topic" + ) as mock_update: + sync_incident_to_slack(incident) + mock_update.assert_not_called() + + def test_handles_invalid_channel_url(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Captain", + last_name="One", + ) + incident.captain = captain + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://invalid-url.com", + ) + + with patch( + "firetower.incidents.services._slack_service.update_channel_topic" + ) as mock_update: + sync_incident_to_slack(incident) + mock_update.assert_not_called() + + def test_handles_slack_api_failure(self): + incident = Incident.objects.create( + title="Test Incident", + status=IncidentStatus.ACTIVE, + severity=IncidentSeverity.P1, + ) + captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Captain", + last_name="One", + ) + incident.captain = captain + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://workspace.slack.com/archives/C12345", + ) + + with patch( + "firetower.incidents.services._slack_service.update_channel_topic" + ) as mock_update: + mock_update.return_value = False + + sync_incident_to_slack(incident) diff --git a/src/firetower/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index b9e8cf77..b0643f69 100644 --- a/src/firetower/integrations/services/slack.py +++ b/src/firetower/integrations/services/slack.py @@ -197,3 +197,30 @@ def get_user_info(self, slack_user_id: str) -> dict | None: extra={"slack_user_id": slack_user_id}, ) return None + + def update_channel_topic(self, channel_id: str, topic: str) -> bool: + """ + Update Slack channel topic. + + Args: + channel_id: Slack channel ID (e.g., C12345678) + topic: New topic string + + Returns: + True if successful, False otherwise + """ + if not self.client: + logger.warning("Cannot update channel topic - Slack client not initialized") + return False + + try: + self.client.conversations_setTopic(channel=channel_id, topic=topic) + logger.info(f"Successfully updated topic for channel {channel_id}") + return True + + except SlackApiError as e: + logger.error( + f"Error updating channel topic: {e.response['error']}", + extra={"channel_id": channel_id}, + ) + return False