Skip to content
Draft
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
7 changes: 7 additions & 0 deletions src/firetower/incidents/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Tag,
TagType,
)
from .services import sync_incident_to_slack


@dataclass
Expand Down Expand Up @@ -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


Expand Down
44 changes: 44 additions & 0 deletions src/firetower/incidents/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
87 changes: 87 additions & 0 deletions src/firetower/incidents/tests/test_serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import patch

import pytest
from django.conf import settings
from django.contrib.auth.models import User
Expand All @@ -14,6 +16,7 @@
from firetower.incidents.serializers import (
IncidentDetailUISerializer,
IncidentListUISerializer,
IncidentWriteSerializer,
)


Expand Down Expand Up @@ -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()
144 changes: 143 additions & 1 deletion src/firetower/incidents/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
27 changes: 27 additions & 0 deletions src/firetower/integrations/services/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading