diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed160b1b..463be47a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -82,6 +82,15 @@ jobs: --container nginx --image "${{ env.STATIC_IMAGE_REF }}" --port 80 --container firetower-backend --image "${{ env.BACKEND_IMAGE_REF }}" + - name: deploy-test-slack-bot + if: ${{ inputs.environment == 'test' }} + uses: "google-github-actions/deploy-cloudrun@v3" + with: + service: firetower-slack-app-test + project_id: ${{ secrets.GCP_PROJECT_SLUG }} + region: us-west1 + image: ${{ env.BACKEND_IMAGE_REF }} + - name: deploy-prod-db-migration if: ${{ github.ref == 'refs/heads/main' && ((!inputs.environment) || inputs.environment == 'prod') }} uses: "google-github-actions/deploy-cloudrun@v3" @@ -102,3 +111,12 @@ jobs: flags: > --container nginx --image "${{ env.STATIC_IMAGE_REF }}" --port 80 --container firetower-backend --image "${{ env.BACKEND_IMAGE_REF }}" + + - name: deploy-prod-slack-bot + if: ${{ github.ref == 'refs/heads/main' && ((!inputs.environment) || inputs.environment == 'prod') }} + uses: "google-github-actions/deploy-cloudrun@v3" + with: + service: firetower-slack-app + project_id: ${{ secrets.GCP_PROJECT_SLUG }} + region: us-west1 + image: ${{ env.BACKEND_IMAGE_REF }} diff --git a/config.ci.toml b/config.ci.toml index 6cd7a164..da2954f6 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -1,6 +1,7 @@ project_key = "INC" django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69" sentry_dsn = "" +firetower_base_url = "http://localhost:5173" # This is the actual essential part. Values match the container set up by GHA [postgres] @@ -16,9 +17,10 @@ api_key = "" severity_field = "" [slack] -bot_token = "" -team_id = "" +bot_token = "test-bot-token" +team_id = "test-bot-id" participant_sync_throttle_seconds = 300 +app_token = "xapp-test-token" [auth] iap_enabled = false diff --git a/config.example.toml b/config.example.toml index 3866f805..ae9b1e1b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,6 +1,7 @@ project_key = "INC" django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69" sentry_dsn = "https://your-sentry-dsn@o1.ingest.us.sentry.io/project-id" +firetower_base_url = "http://localhost:5173" [postgres] db = "firetower" @@ -18,6 +19,7 @@ severity_field = "customfield_11023" bot_token = "" team_id = "" participant_sync_throttle_seconds = 300 +app_token = "" [auth] iap_enabled = false diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e01da76e..b250e3a6 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -8,8 +8,10 @@ if [ z"$1" = "zmigrate" ]; then COMMAND="/app/.venv/bin/django-admin migrate --settings firetower.settings" elif [ z"$1" = "zserver" ]; then COMMAND="/app/.venv/bin/granian --interface wsgi --host 0.0.0.0 --port $PORT firetower.wsgi:application" +elif [ z"$1" = "zslack-bot" ]; then + COMMAND="/app/.venv/bin/django-admin run_slack_bot --settings firetower.settings" else - echo "Usage: $0 (migrate|server)" + echo "Usage: $0 (migrate|server|slack-bot)" exit 1 fi diff --git a/pyproject.toml b/pyproject.toml index 0c9ee6c0..86228cad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "jira>=3.5.0", "psycopg[binary]>=3.2.11", "pyserde[toml]>=0.28.0", + "slack-bolt>=1.27.0", "slack-sdk>=3.31.0", ] diff --git a/src/firetower/config.py b/src/firetower/config.py index be93e3bb..3db95215 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -39,6 +39,7 @@ class SlackConfig: bot_token: str team_id: str participant_sync_throttle_seconds: int + app_token: str @deserialize @@ -62,6 +63,7 @@ class ConfigFile: project_key: str django_secret_key: str sentry_dsn: str + firetower_base_url: str pinned_regions: list[str] = field(default_factory=list) @classmethod @@ -115,6 +117,7 @@ def __init__(self) -> None: bot_token="", team_id="", participant_sync_throttle_seconds=0, + app_token="", ) self.auth = AuthConfig( iap_enabled=False, @@ -125,3 +128,4 @@ def __init__(self) -> None: self.django_secret_key = "" self.sentry_dsn = "" self.pinned_regions: list[str] = [] + self.firetower_base_url = "" diff --git a/src/firetower/incidents/filters.py b/src/firetower/incidents/filters.py index da99a48c..aa9c08d4 100644 --- a/src/firetower/incidents/filters.py +++ b/src/firetower/incidents/filters.py @@ -1,6 +1,7 @@ from datetime import datetime from django.db.models import QuerySet +from django.utils import timezone as django_timezone from django.utils.dateparse import parse_datetime from rest_framework.exceptions import ValidationError from rest_framework.request import Request @@ -13,13 +14,15 @@ def parse_date_param(value: str) -> datetime | None: if not value: return None dt = parse_datetime(value) - if dt: - return dt - # Try parsing as date-only (YYYY-MM-DD) - try: - return datetime.fromisoformat(value) - except ValueError: - return None + if dt is None: + # Try parsing as date-only (YYYY-MM-DD) + try: + dt = datetime.fromisoformat(value) + except ValueError: + return None + if django_timezone.is_naive(dt): + dt = django_timezone.make_aware(dt) + return dt def filter_by_date_range( diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py new file mode 100644 index 00000000..996cde55 --- /dev/null +++ b/src/firetower/incidents/hooks.py @@ -0,0 +1,160 @@ +import logging + +from django.conf import settings +from django.contrib.auth.models import User + +from firetower.auth.models import ExternalProfileType +from firetower.incidents.models import ExternalLink, ExternalLinkType, Incident +from firetower.integrations.services import SlackService + +logger = logging.getLogger(__name__) +_slack_service = SlackService() + + +def _build_channel_name(incident: Incident) -> str: + return incident.incident_number.lower() + + +SLACK_TOPIC_MAX_LENGTH = 250 + + +def _build_channel_topic(incident: Incident) -> str: + captain_name = "" + if incident.captain: + captain_name = incident.captain.get_full_name() or incident.captain.username + prefix = f"[{incident.severity}] {incident.incident_number} " + suffix = f" | IC: @{captain_name}" + max_title_len = max(SLACK_TOPIC_MAX_LENGTH - len(prefix) - len(suffix), 0) + title = incident.title + if len(title) > max_title_len: + title = title[: max_title_len - 1] + "\u2026" if max_title_len > 0 else "" + topic = f"{prefix}{title}{suffix}" + return topic[:SLACK_TOPIC_MAX_LENGTH] + + +def _get_channel_id(incident: Incident) -> str | None: + slack_link = incident.external_links.filter(type=ExternalLinkType.SLACK).first() + if not slack_link: + return None + return _slack_service.parse_channel_id_from_url(slack_link.url) + + +def _invite_user_to_channel(channel_id: str, user: User) -> None: + try: + slack_profile = user.external_profiles.filter( + type=ExternalProfileType.SLACK + ).first() + if slack_profile: + _slack_service.invite_to_channel(channel_id, [slack_profile.external_id]) + except Exception: + logger.exception(f"Failed to invite user {user.id} to channel {channel_id}") + + +def on_incident_created(incident: Incident) -> None: + try: + existing_slack_link = incident.external_links.filter( + type=ExternalLinkType.SLACK + ).exists() + if existing_slack_link: + logger.info( + f"Incident {incident.id} already has a Slack link, skipping channel creation" + ) + return + + channel_id = _slack_service.create_channel(_build_channel_name(incident)) + if not channel_id: + logger.warning(f"Failed to create Slack channel for incident {incident.id}") + return + + channel_url = _slack_service.build_channel_url(channel_id) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url=channel_url, + ) + + _slack_service.set_channel_topic(channel_id, _build_channel_topic(incident)) + + base_url = settings.FIRETOWER_BASE_URL + incident_url = f"{base_url}/incidents/{incident.incident_number}" + _slack_service.add_bookmark(channel_id, "Firetower Incident", incident_url) + + _slack_service.post_message( + channel_id, + f"*{incident.incident_number}: {incident.title}*\n" + f"Severity: {incident.severity} | Status: {incident.status}", + ) + + if incident.captain: + _invite_user_to_channel(channel_id, incident.captain) + + # TODO: Datadog notebook creation step will be added in RELENG-467 + except Exception: + logger.exception(f"Error in on_incident_created for incident {incident.id}") + + +def on_status_changed(incident: Incident, old_status: str) -> None: + try: + channel_id = _get_channel_id(incident) + if not channel_id: + return + + _slack_service.post_message( + channel_id, + f"Status changed: {old_status} -> {incident.status}", + ) + _slack_service.set_channel_topic(channel_id, _build_channel_topic(incident)) + except Exception: + logger.exception(f"Error in on_status_changed for incident {incident.id}") + + +def on_severity_changed(incident: Incident, old_severity: str) -> None: + try: + channel_id = _get_channel_id(incident) + if not channel_id: + return + + _slack_service.post_message( + channel_id, + f"Severity changed: {old_severity} -> {incident.severity}", + ) + _slack_service.set_channel_topic(channel_id, _build_channel_topic(incident)) + except Exception: + logger.exception(f"Error in on_severity_changed for incident {incident.id}") + + +def on_title_changed(incident: Incident, old_title: str) -> None: + try: + channel_id = _get_channel_id(incident) + if not channel_id: + return + + _slack_service.post_message( + channel_id, + f"Title changed: {old_title} -> {incident.title}", + ) + _slack_service.set_channel_topic(channel_id, _build_channel_topic(incident)) + except Exception: + logger.exception(f"Error in on_title_changed for incident {incident.id}") + + +def on_captain_changed(incident: Incident) -> None: + try: + channel_id = _get_channel_id(incident) + if not channel_id: + return + + _slack_service.set_channel_topic(channel_id, _build_channel_topic(incident)) + + captain_name = "" + if incident.captain: + captain_name = incident.captain.get_full_name() or incident.captain.username + _slack_service.post_message( + channel_id, + f"Incident captain changed to @{captain_name}", + ) + + if incident.captain: + _invite_user_to_channel(channel_id, incident.captain) + except Exception: + logger.exception(f"Error in on_captain_changed for incident {incident.id}") diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index 871a374a..7fe8ac43 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -8,6 +8,13 @@ from firetower.auth.services import get_or_create_user_from_email +from .hooks import ( + on_captain_changed, + on_incident_created, + on_severity_changed, + on_status_changed, + on_title_changed, +) from .models import ( USER_ADDABLE_TAG_TYPES, ExternalLink, @@ -501,6 +508,10 @@ def create(self, validated_data: dict) -> Incident: ) incident.impact_type_tags.set(tags) + # Runs synchronously — Slack API calls may add latency to the response. + # Consider deferring to a background task if this becomes a problem. + on_incident_created(incident) + return incident def update(self, instance: Incident, validated_data: dict) -> Incident: @@ -521,6 +532,11 @@ def update(self, instance: Incident, validated_data: dict) -> Incident: root_cause_tag_names = validated_data.pop("root_cause_tag_names", None) impact_type_tag_names = validated_data.pop("impact_type_tag_names", None) + old_status = instance.status + old_severity = instance.severity + old_captain_id = instance.captain_id + old_title = instance.title + # Update basic fields instance = super().update(instance, validated_data) @@ -574,6 +590,15 @@ def update(self, instance: Incident, validated_data: dict) -> Incident: ) instance.impact_type_tags.set(tags) + if instance.status != old_status: + on_status_changed(instance, old_status) + if instance.severity != old_severity: + on_severity_changed(instance, old_severity) + if instance.captain_id != old_captain_id: + on_captain_changed(instance) + if instance.title != old_title: + on_title_changed(instance, old_title) + return instance diff --git a/src/firetower/incidents/tests/test_filters.py b/src/firetower/incidents/tests/test_filters.py index 445e71a1..14f41c7a 100644 --- a/src/firetower/incidents/tests/test_filters.py +++ b/src/firetower/incidents/tests/test_filters.py @@ -104,10 +104,10 @@ def test_filter_by_created_after(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -129,10 +129,10 @@ def test_filter_by_created_before(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -159,13 +159,13 @@ def test_filter_by_date_range(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) Incident.objects.filter(pk=inc3.pk).update( - created_at=datetime(2024, 12, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 12, 1, 0, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -184,7 +184,7 @@ def test_filter_by_datetime_with_time(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc.pk).update( - created_at=datetime(2024, 6, 15, 14, 30, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 14, 30, 0)) ) self.client.force_authenticate(user=self.user) @@ -537,10 +537,10 @@ def test_filter_by_date_range(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -636,13 +636,13 @@ def test_filter_by_severity_and_date(self): severity=IncidentSeverity.P2, ) Incident.objects.filter(pk=Incident.objects.get(title="P1 Old").pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=Incident.objects.get(title="P2 Old").pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=Incident.objects.get(title="P1 New").pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py new file mode 100644 index 00000000..0ef5341f --- /dev/null +++ b/src/firetower/incidents/tests/test_hooks.py @@ -0,0 +1,281 @@ +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User + +from firetower.auth.models import ExternalProfile, ExternalProfileType +from firetower.incidents.hooks import ( + _build_channel_name, + _build_channel_topic, + on_captain_changed, + on_incident_created, + on_severity_changed, + on_status_changed, +) +from firetower.incidents.models import ( + ExternalLink, + ExternalLinkType, + Incident, + IncidentSeverity, + IncidentStatus, +) + + +@pytest.mark.django_db +class TestBuildChannelName: + def test_format(self): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + ) + assert _build_channel_name(incident) == incident.incident_number.lower() + + +@pytest.mark.django_db +class TestBuildChannelTopic: + def test_format_with_captain(self): + captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Jane", + last_name="Doe", + ) + incident = Incident.objects.create( + title="Database connection pool exhausted", + severity=IncidentSeverity.P1, + captain=captain, + ) + topic = _build_channel_topic(incident) + assert topic == ( + f"[P1] {incident.incident_number} Database connection pool exhausted" + " | IC: @Jane Doe" + ) + + def test_format_without_captain(self): + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P2, + ) + topic = _build_channel_topic(incident) + assert topic == (f"[P2] {incident.incident_number} Test Incident | IC: @") + + def test_long_title_is_truncated(self): + long_title = "A" * 300 + incident = Incident.objects.create( + title=long_title, + severity=IncidentSeverity.P1, + ) + topic = _build_channel_topic(incident) + assert len(topic) <= 250 + assert topic.endswith("| IC: @") + assert "\u2026" in topic + + +@pytest.mark.django_db +class TestOnIncidentCreated: + def setup_method(self): + self.captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Jane", + last_name="Captain", + ) + self.reporter = User.objects.create_user( + username="reporter@example.com", + email="reporter@example.com", + ) + + @patch("firetower.incidents.hooks._slack_service") + def test_creates_channel_and_link(self, mock_slack): + mock_slack.create_channel.return_value = "C99999" + mock_slack.build_channel_url.return_value = "https://slack.com/archives/C99999" + + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + captain=self.captain, + reporter=self.reporter, + ) + + on_incident_created(incident) + + mock_slack.create_channel.assert_called_once_with( + incident.incident_number.lower() + ) + link = ExternalLink.objects.get(incident=incident, type=ExternalLinkType.SLACK) + assert link.url == "https://slack.com/archives/C99999" + mock_slack.set_channel_topic.assert_called_once() + mock_slack.add_bookmark.assert_called_once() + mock_slack.post_message.assert_called_once() + + @patch("firetower.incidents.hooks._slack_service") + def test_skips_if_slack_link_exists(self, mock_slack): + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + captain=self.captain, + ) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C00000", + ) + + on_incident_created(incident) + + mock_slack.create_channel.assert_not_called() + + @patch("firetower.incidents.hooks._slack_service") + def test_handles_create_channel_failure(self, mock_slack): + mock_slack.create_channel.return_value = None + + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + ) + + on_incident_created(incident) + + assert not ExternalLink.objects.filter( + incident=incident, type=ExternalLinkType.SLACK + ).exists() + + @patch("firetower.incidents.hooks._slack_service") + def test_invites_captain_with_slack_profile(self, mock_slack): + mock_slack.create_channel.return_value = "C99999" + mock_slack.build_channel_url.return_value = "https://slack.com/archives/C99999" + + ExternalProfile.objects.create( + user=self.captain, + type=ExternalProfileType.SLACK, + external_id="U_CAPTAIN", + ) + + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + captain=self.captain, + ) + + on_incident_created(incident) + + mock_slack.invite_to_channel.assert_called_once_with("C99999", ["U_CAPTAIN"]) + + +@pytest.mark.django_db +class TestOnStatusChanged: + @patch("firetower.incidents.hooks._slack_service") + def test_posts_and_updates_topic(self, mock_slack): + mock_slack.parse_channel_id_from_url.return_value = "C12345" + + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + status=IncidentStatus.MITIGATED, + ) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C12345", + ) + + on_status_changed(incident, IncidentStatus.ACTIVE) + + mock_slack.post_message.assert_called_once() + assert "Active" in mock_slack.post_message.call_args[0][1] + assert "Mitigated" in mock_slack.post_message.call_args[0][1] + mock_slack.set_channel_topic.assert_called_once() + + @patch("firetower.incidents.hooks._slack_service") + def test_noop_without_slack_link(self, mock_slack): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + ) + + on_status_changed(incident, IncidentStatus.ACTIVE) + + mock_slack.post_message.assert_not_called() + + +@pytest.mark.django_db +class TestOnSeverityChanged: + @patch("firetower.incidents.hooks._slack_service") + def test_posts_and_updates_topic(self, mock_slack): + mock_slack.parse_channel_id_from_url.return_value = "C12345" + + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P0, + ) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C12345", + ) + + on_severity_changed(incident, IncidentSeverity.P2) + + mock_slack.post_message.assert_called_once() + assert "P2" in mock_slack.post_message.call_args[0][1] + assert "P0" in mock_slack.post_message.call_args[0][1] + mock_slack.set_channel_topic.assert_called_once() + + @patch("firetower.incidents.hooks._slack_service") + def test_noop_without_slack_link(self, mock_slack): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + ) + + on_severity_changed(incident, IncidentSeverity.P2) + + mock_slack.post_message.assert_not_called() + + +@pytest.mark.django_db +class TestOnCaptainChanged: + @patch("firetower.incidents.hooks._slack_service") + def test_updates_topic_and_invites(self, mock_slack): + mock_slack.parse_channel_id_from_url.return_value = "C12345" + + captain = User.objects.create_user( + username="newcaptain@example.com", + email="newcaptain@example.com", + first_name="New", + last_name="Captain", + ) + ExternalProfile.objects.create( + user=captain, + type=ExternalProfileType.SLACK, + external_id="U_NEW", + ) + + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + captain=captain, + ) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C12345", + ) + + on_captain_changed(incident) + + mock_slack.set_channel_topic.assert_called_once() + mock_slack.post_message.assert_called_once() + assert "New Captain" in mock_slack.post_message.call_args[0][1] + mock_slack.invite_to_channel.assert_called_once_with("C12345", ["U_NEW"]) + + @patch("firetower.incidents.hooks._slack_service") + def test_noop_without_slack_link(self, mock_slack): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + ) + + on_captain_changed(incident) + + mock_slack.set_channel_topic.assert_not_called() diff --git a/src/firetower/incidents/tests/test_serializers.py b/src/firetower/incidents/tests/test_serializers.py index b4be67ba..c781473f 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,158 @@ 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 TestIncidentWriteSerializerHooks: + def setup_method(self): + self.captain = User.objects.create_user( + username="captain@example.com", + email="captain@example.com", + first_name="Jane", + last_name="Captain", + ) + self.reporter = User.objects.create_user( + username="reporter@example.com", + email="reporter@example.com", + first_name="John", + last_name="Reporter", + ) + + @patch("firetower.incidents.serializers.on_incident_created") + def test_create_calls_on_incident_created(self, mock_hook): + serializer = IncidentWriteSerializer( + data={ + "title": "Test", + "severity": "P1", + "captain": "captain@example.com", + "reporter": "reporter@example.com", + } + ) + assert serializer.is_valid(), serializer.errors + incident = serializer.save() + mock_hook.assert_called_once_with(incident) + + @patch("firetower.incidents.serializers.on_status_changed") + def test_update_calls_on_status_changed(self, mock_hook): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + status=IncidentStatus.ACTIVE, + captain=self.captain, + reporter=self.reporter, + ) + serializer = IncidentWriteSerializer( + instance=incident, + data={"status": "Mitigated"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + mock_hook.assert_called_once_with(incident, IncidentStatus.ACTIVE) + + @patch("firetower.incidents.serializers.on_severity_changed") + def test_update_calls_on_severity_changed(self, mock_hook): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P2, + captain=self.captain, + reporter=self.reporter, + ) + serializer = IncidentWriteSerializer( + instance=incident, + data={"severity": "P0"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + mock_hook.assert_called_once_with(incident, IncidentSeverity.P2) + + @patch("firetower.incidents.serializers.on_captain_changed") + def test_update_calls_on_captain_changed(self, mock_hook): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + captain=self.captain, + reporter=self.reporter, + ) + User.objects.create_user( + username="new@example.com", + email="new@example.com", + ) + serializer = IncidentWriteSerializer( + instance=incident, + data={"captain": "new@example.com"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + mock_hook.assert_called_once_with(incident) + + @patch("firetower.incidents.serializers.on_status_changed") + @patch("firetower.incidents.serializers.on_severity_changed") + @patch("firetower.incidents.serializers.on_captain_changed") + def test_update_no_hooks_when_fields_unchanged( + self, mock_captain, mock_severity, mock_status + ): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + captain=self.captain, + reporter=self.reporter, + ) + serializer = IncidentWriteSerializer( + instance=incident, + data={"title": "Updated Title"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + mock_status.assert_not_called() + mock_severity.assert_not_called() + mock_captain.assert_not_called() + + @patch("firetower.incidents.hooks._slack_service") + def test_update_same_status_string_does_not_fire_hook(self, mock_slack): + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + status=IncidentStatus.ACTIVE, + captain=self.captain, + reporter=self.reporter, + ) + serializer = IncidentWriteSerializer( + instance=incident, + data={"status": "Active"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + mock_slack.post_message.assert_not_called() + + @patch("firetower.incidents.hooks._slack_service") + def test_update_different_status_string_fires_hook(self, mock_slack): + mock_slack.parse_channel_id_from_url.return_value = "C12345" + incident = Incident.objects.create( + title="Test", + severity=IncidentSeverity.P1, + status=IncidentStatus.ACTIVE, + captain=self.captain, + reporter=self.reporter, + ) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C12345", + ) + serializer = IncidentWriteSerializer( + instance=incident, + data={"status": "Mitigated"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + mock_slack.post_message.assert_called_once() + msg = mock_slack.post_message.call_args[0][1] + assert "Active" in msg + assert "Mitigated" in msg diff --git a/src/firetower/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index b9e8cf77..9a05ce2e 100644 --- a/src/firetower/integrations/services/slack.py +++ b/src/firetower/integrations/services/slack.py @@ -150,6 +150,119 @@ def get_channel_members(self, channel_id: str) -> list[str] | None: ) return None + def create_channel(self, name: str) -> str | None: + if not self.client: + logger.warning("Cannot create channel - Slack client not initialized") + return None + + try: + logger.info(f"Creating Slack channel: {name}") + response = self.client.conversations_create(name=name) + channel_id: str = response["channel"]["id"] + logger.info(f"Created Slack channel {name} with ID {channel_id}") + return channel_id + except SlackApiError as e: + logger.error( + f"Error creating Slack channel: {e}", + extra={"channel_name": name}, + ) + return None + + def set_channel_topic(self, channel_id: str, topic: str) -> bool: + if not self.client: + logger.warning("Cannot set topic - Slack client not initialized") + return False + + try: + logger.info(f"Setting topic for channel {channel_id}") + self.client.conversations_setTopic(channel=channel_id, topic=topic) + return True + except SlackApiError as e: + logger.error( + f"Error setting channel topic: {e}", + extra={"channel_id": channel_id}, + ) + return False + + def invite_to_channel(self, channel_id: str, user_ids: list[str]) -> bool: + if not self.client: + logger.warning("Cannot invite to channel - Slack client not initialized") + return False + + try: + logger.info(f"Inviting {len(user_ids)} users to channel {channel_id}") + self.client.conversations_invite( + channel=channel_id, users=",".join(user_ids) + ) + return True + except SlackApiError as e: + logger.error( + f"Error inviting to channel: {e}", + extra={"channel_id": channel_id}, + ) + return False + + def post_message( + self, channel_id: str, text: str, blocks: list[dict] | None = None + ) -> bool: + if not self.client: + logger.warning("Cannot post message - Slack client not initialized") + return False + + try: + logger.info(f"Posting message to channel {channel_id}") + self.client.chat_postMessage(channel=channel_id, text=text, blocks=blocks) + return True + except SlackApiError as e: + logger.error( + f"Error posting message: {e}", extra={"channel_id": channel_id} + ) + return False + + def add_bookmark(self, channel_id: str, title: str, link: str) -> bool: + if not self.client: + logger.warning("Cannot add bookmark - Slack client not initialized") + return False + + try: + logger.info(f"Adding bookmark to channel {channel_id}") + self.client.bookmarks_add( + channel_id=channel_id, title=title, type="link", link=link + ) + return True + except SlackApiError as e: + logger.error( + f"Error adding bookmark: {e}", extra={"channel_id": channel_id} + ) + return False + + def get_channel_history( + self, channel_id: str, limit: int = 1000 + ) -> list[dict] | None: + if not self.client: + logger.warning( + "Cannot fetch channel history - Slack client not initialized" + ) + return None + + try: + logger.info(f"Fetching history for channel {channel_id}") + response = self.client.conversations_history( + channel=channel_id, limit=limit + ) + messages: list[dict] = response.get("messages", []) + logger.info(f"Found {len(messages)} messages in channel {channel_id}") + return messages + except SlackApiError as e: + logger.error( + f"Error fetching channel history: {e}", + extra={"channel_id": channel_id}, + ) + return None + + def build_channel_url(self, channel_id: str) -> str: + return f"https://slack.com/archives/{channel_id}" + def get_user_info(self, slack_user_id: str) -> dict | None: """ Get user information from Slack by user ID. diff --git a/src/firetower/integrations/test_slack.py b/src/firetower/integrations/test_slack.py index 7618f004..d8e61c96 100644 --- a/src/firetower/integrations/test_slack.py +++ b/src/firetower/integrations/test_slack.py @@ -139,3 +139,169 @@ def test_get_user_profile_without_client(self): profile = service.get_user_profile_by_email("test@example.com") assert profile is None + + def _make_service(self): + mock_slack_config = { + "BOT_TOKEN": "xoxb-test-token", + "TEAM_ID": "sentry", + } + with patch.object(settings, "SLACK", mock_slack_config): + with patch("firetower.integrations.services.slack.WebClient") as MockClient: + mock_client = MagicMock() + MockClient.return_value = mock_client + service = SlackService() + return service, mock_client + + def test_create_channel_success(self): + service, mock_client = self._make_service() + mock_client.conversations_create.return_value = {"channel": {"id": "C12345"}} + result = service.create_channel("inc-2014") + assert result == "C12345" + mock_client.conversations_create.assert_called_once_with(name="inc-2014") + + def test_create_channel_no_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.create_channel("inc-2014") is None + + def test_create_channel_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_response.get.return_value = "name_taken" + mock_client.conversations_create.side_effect = SlackApiError( + "name_taken", mock_response + ) + assert service.create_channel("inc-2014") is None + + def test_set_channel_topic_success(self): + service, mock_client = self._make_service() + assert service.set_channel_topic("C12345", "test topic") is True + mock_client.conversations_setTopic.assert_called_once_with( + channel="C12345", topic="test topic" + ) + + def test_set_channel_topic_no_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.set_channel_topic("C12345", "topic") is False + + def test_set_channel_topic_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_client.conversations_setTopic.side_effect = SlackApiError( + "error", mock_response + ) + assert service.set_channel_topic("C12345", "topic") is False + + def test_invite_to_channel_success(self): + service, mock_client = self._make_service() + assert service.invite_to_channel("C12345", ["U111", "U222"]) is True + mock_client.conversations_invite.assert_called_once_with( + channel="C12345", users="U111,U222" + ) + + def test_invite_to_channel_no_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.invite_to_channel("C12345", ["U111"]) is False + + def test_invite_to_channel_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_client.conversations_invite.side_effect = SlackApiError( + "error", mock_response + ) + assert service.invite_to_channel("C12345", ["U111"]) is False + + def test_post_message_success(self): + service, mock_client = self._make_service() + assert service.post_message("C12345", "hello") is True + mock_client.chat_postMessage.assert_called_once_with( + channel="C12345", text="hello", blocks=None + ) + + def test_post_message_with_blocks(self): + service, mock_client = self._make_service() + blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "hi"}}] + assert service.post_message("C12345", "hello", blocks=blocks) is True + mock_client.chat_postMessage.assert_called_once_with( + channel="C12345", text="hello", blocks=blocks + ) + + def test_post_message_no_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.post_message("C12345", "hello") is False + + def test_post_message_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_client.chat_postMessage.side_effect = SlackApiError("error", mock_response) + assert service.post_message("C12345", "hello") is False + + def test_add_bookmark_success(self): + service, mock_client = self._make_service() + assert ( + service.add_bookmark("C12345", "Firetower", "https://example.com") is True + ) + mock_client.bookmarks_add.assert_called_once_with( + channel_id="C12345", + title="Firetower", + type="link", + link="https://example.com", + ) + + def test_add_bookmark_no_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.add_bookmark("C12345", "title", "https://example.com") is False + + def test_add_bookmark_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_client.bookmarks_add.side_effect = SlackApiError("error", mock_response) + assert service.add_bookmark("C12345", "title", "https://example.com") is False + + def test_get_channel_history_success(self): + service, mock_client = self._make_service() + mock_client.conversations_history.return_value = { + "messages": [{"text": "hello"}, {"text": "world"}] + } + result = service.get_channel_history("C12345") + assert result == [{"text": "hello"}, {"text": "world"}] + mock_client.conversations_history.assert_called_once_with( + channel="C12345", limit=1000 + ) + + def test_get_channel_history_custom_limit(self): + service, mock_client = self._make_service() + mock_client.conversations_history.return_value = {"messages": []} + service.get_channel_history("C12345", limit=10) + mock_client.conversations_history.assert_called_once_with( + channel="C12345", limit=10 + ) + + def test_get_channel_history_no_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.get_channel_history("C12345") is None + + def test_get_channel_history_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_client.conversations_history.side_effect = SlackApiError( + "error", mock_response + ) + assert service.get_channel_history("C12345") is None + + def test_build_channel_url(self): + service, _ = self._make_service() + url = service.build_channel_url("C12345") + assert url == "https://slack.com/archives/C12345" + assert service.parse_channel_id_from_url(url) == "C12345" diff --git a/src/firetower/settings.py b/src/firetower/settings.py index b5cbf59a..88516b00 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -105,6 +105,7 @@ def cmd_needs_dummy_config() -> bool: "firetower.auth", "firetower.incidents", "firetower.integrations", + "firetower.slack_app", ] MIDDLEWARE = [ @@ -209,10 +210,13 @@ def cmd_needs_dummy_config() -> bool: SLACK = { "BOT_TOKEN": config.slack.bot_token, "TEAM_ID": config.slack.team_id, + "APP_TOKEN": config.slack.app_token, } PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds) +FIRETOWER_BASE_URL = config.firetower_base_url + # Django REST Framework Configuration REST_FRAMEWORK = { # Pagination diff --git a/src/firetower/slack_app/__init__.py b/src/firetower/slack_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/slack_app/apps.py b/src/firetower/slack_app/apps.py new file mode 100644 index 00000000..9365c122 --- /dev/null +++ b/src/firetower/slack_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SlackAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "firetower.slack_app" diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py new file mode 100644 index 00000000..625ce513 --- /dev/null +++ b/src/firetower/slack_app/bolt.py @@ -0,0 +1,104 @@ +import logging +from typing import Any + +from datadog import statsd +from django.conf import settings +from slack_bolt import App + +from firetower.slack_app.handlers.help import handle_help_command +from firetower.slack_app.handlers.mitigated import ( + handle_mitigated_command, + handle_mitigated_submission, +) +from firetower.slack_app.handlers.new_incident import ( + handle_new_command, + handle_new_incident_submission, +) +from firetower.slack_app.handlers.reopen import handle_reopen_command +from firetower.slack_app.handlers.resolved import ( + handle_resolved_command, + handle_resolved_submission, +) +from firetower.slack_app.handlers.severity import handle_severity_command +from firetower.slack_app.handlers.subject import handle_subject_command + +logger = logging.getLogger(__name__) + +METRICS_PREFIX = "slack_app.commands" + +KNOWN_SUBCOMMANDS = { + "help", + "new", + "mitigated", + "mit", + "resolved", + "fixed", + "reopen", + "severity", + "sev", + "setseverity", + "subject", +} + +slack_config = settings.SLACK + +bolt_app = App(token=slack_config["BOT_TOKEN"]) + + +@bolt_app.command("/ft") +@bolt_app.command("/ft-test") +def handle_inc(ack: Any, body: dict, command: dict, respond: Any) -> None: + raw_text = (body.get("text") or "").strip() + parts = raw_text.split(None, 1) + subcommand = parts[0].lower() if parts else "" + args = parts[1] if len(parts) > 1 else "" + + metric_subcommand = ( + (subcommand or "help") + if subcommand in KNOWN_SUBCOMMANDS or subcommand == "" + else "unknown" + ) + tags = [f"subcommand:{metric_subcommand}"] + statsd.increment(f"{METRICS_PREFIX}.submitted", tags=tags) + + try: + if subcommand == "new": + handle_new_command(ack, body, command, respond) + elif subcommand in ("help", ""): + handle_help_command(ack, command, respond) + elif subcommand in ("mitigated", "mit"): + handle_mitigated_command(ack, body, command, respond) + elif subcommand in ("resolved", "fixed"): + handle_resolved_command(ack, body, command, respond) + elif subcommand == "reopen": + handle_reopen_command(ack, body, command, respond) + elif subcommand in ("severity", "sev", "setseverity"): + if not args: + ack() + cmd = command.get("command", "/ft") + respond(f"Usage: `{cmd} severity `") + else: + handle_severity_command(ack, body, command, respond, new_severity=args) + elif subcommand == "subject": + if not args: + ack() + cmd = command.get("command", "/ft") + respond(f"Usage: `{cmd} subject `") + else: + handle_subject_command(ack, body, command, respond, new_subject=args) + else: + ack() + cmd = command.get("command", "/ft") + respond(f"Unknown command: `{cmd} {subcommand}`. Try `{cmd} help`.") + statsd.increment(f"{METRICS_PREFIX}.completed", tags=tags) + except Exception: + logger.exception( + "Slash command failed: %s %s", command.get("command", "/ft"), subcommand + ) + statsd.increment(f"{METRICS_PREFIX}.failed", tags=tags) + raise + + +bolt_app.view("new_incident_modal")(handle_new_incident_submission) +bolt_app.view("mitigated_incident_modal")(handle_mitigated_submission) +bolt_app.view("resolved_incident_modal")(handle_resolved_submission) diff --git a/src/firetower/slack_app/handlers/__init__.py b/src/firetower/slack_app/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py new file mode 100644 index 00000000..5bfd15b5 --- /dev/null +++ b/src/firetower/slack_app/handlers/help.py @@ -0,0 +1,18 @@ +from typing import Any + + +def handle_help_command(ack: Any, command: dict, respond: Any) -> None: + ack() + cmd = command.get("command", "/ft") + respond( + f"*Firetower Slack App*\n" + f"Usage: `{cmd} `\n\n" + f"Available commands:\n" + f" `{cmd} new` - Create a new incident\n" + f" `{cmd} mitigated` - Mark incident as mitigated\n" + f" `{cmd} resolved` - Mark incident as resolved\n" + f" `{cmd} reopen` - Reopen an incident\n" + f" `{cmd} severity ` - Change incident severity\n" + f" `{cmd} subject ` - Change incident title\n" + f" `{cmd} help` - Show this help message\n" + ) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py new file mode 100644 index 00000000..877da6d6 --- /dev/null +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -0,0 +1,125 @@ +import logging +from typing import Any + +from firetower.incidents.serializers import IncidentWriteSerializer +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + + +def _build_mitigated_modal(incident_number: str, channel_id: str) -> dict: + return { + "type": "modal", + "callback_id": "mitigated_incident_modal", + "private_metadata": channel_id, + "title": {"type": "plain_text", "text": incident_number}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Mark this incident as mitigated. Please provide the current impact and any remaining action items.", + }, + }, + { + "type": "input", + "block_id": "impact_block", + "element": { + "type": "plain_text_input", + "action_id": "impact_update", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "What is the current impact after mitigation?", + }, + }, + "label": { + "type": "plain_text", + "text": "Current impact post-mitigation", + }, + }, + { + "type": "input", + "block_id": "todo_block", + "element": { + "type": "plain_text_input", + "action_id": "todo_update", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "What still needs to be done?", + }, + }, + "label": {"type": "plain_text", "text": "Remaining action items"}, + }, + ], + } + + +def handle_mitigated_command(ack: Any, body: dict, command: dict, respond: Any) -> None: + ack() + channel_id = body.get("channel_id", "") + incident = get_incident_from_channel(channel_id) + if not incident: + respond("Could not find an incident associated with this channel.") + return + + trigger_id = body.get("trigger_id") + if not trigger_id: + respond("Could not open modal — missing trigger_id.") + return + + from firetower.slack_app.bolt import bolt_app # noqa: PLC0415 + + bolt_app.client.views_open( + trigger_id=trigger_id, + view=_build_mitigated_modal(incident.incident_number, channel_id), + ) + + +def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) -> None: + ack() + values = view.get("state", {}).get("values", {}) + channel_id = view.get("private_metadata", "") + + impact = values.get("impact_block", {}).get("impact_update", {}).get("value", "") + todo = values.get("todo_block", {}).get("todo_update", {}).get("value", "") + + incident = get_incident_from_channel(channel_id) + if not incident: + logger.error("Mitigated submission: no incident for channel %s", channel_id) + return + + serializer = IncidentWriteSerializer( + instance=incident, data={"status": "Mitigated"}, partial=True + ) + if not serializer.is_valid(): + logger.error("Mitigated status update failed: %s", serializer.errors) + client.chat_postMessage( + channel=channel_id, + text=f"Failed to update incident status: {serializer.errors}", + ) + return + serializer.save() + + incident.refresh_from_db() + mitigation_notes = f"\n\n---\n**Mitigation notes:**\n**Impact:** {impact}\n**Action items:** {todo}" + new_description = (incident.description or "") + mitigation_notes + desc_serializer = IncidentWriteSerializer( + instance=incident, data={"description": new_description}, partial=True + ) + if not desc_serializer.is_valid(): + logger.error("Mitigated description update failed: %s", desc_serializer.errors) + else: + desc_serializer.save() + + client.chat_postMessage( + channel=channel_id, + text=( + f"*{incident.incident_number} marked as Mitigated*\n" + f"*Impact:* {impact}\n" + f"*Action items:* {todo}" + ), + ) diff --git a/src/firetower/slack_app/handlers/new_incident.py b/src/firetower/slack_app/handlers/new_incident.py new file mode 100644 index 00000000..babf8de3 --- /dev/null +++ b/src/firetower/slack_app/handlers/new_incident.py @@ -0,0 +1,280 @@ +import logging +from typing import Any + +from django.conf import settings + +from firetower.auth.services import get_or_create_user_from_slack_id +from firetower.incidents.models import IncidentSeverity, Tag, TagType +from firetower.incidents.serializers import IncidentWriteSerializer + +logger = logging.getLogger(__name__) + + +def _build_new_incident_modal() -> dict: + severity_options = [ + { + "text": {"type": "plain_text", "text": sev.label}, + "value": sev.value, + } + for sev in IncidentSeverity + ] + + impact_type_options = [ + {"text": {"type": "plain_text", "text": t.name}, "value": t.name} + for t in Tag.objects.filter(type=TagType.IMPACT_TYPE).order_by("name") + ] + affected_service_options = [ + {"text": {"type": "plain_text", "text": t.name}, "value": t.name} + for t in Tag.objects.filter(type=TagType.AFFECTED_SERVICE).order_by("name") + ] + affected_region_options = [ + {"text": {"type": "plain_text", "text": t.name}, "value": t.name} + for t in Tag.objects.filter(type=TagType.AFFECTED_REGION).order_by("name") + ] + + blocks = [ + { + "type": "input", + "block_id": "severity_block", + "element": { + "type": "static_select", + "action_id": "severity", + "placeholder": {"type": "plain_text", "text": "Select severity"}, + "options": severity_options, + "initial_option": severity_options[2], # P2 + }, + "label": {"type": "plain_text", "text": "Severity"}, + }, + { + "type": "input", + "block_id": "title_block", + "element": { + "type": "plain_text_input", + "action_id": "title", + "placeholder": {"type": "plain_text", "text": "Brief incident title"}, + }, + "label": {"type": "plain_text", "text": "Title"}, + }, + { + "type": "input", + "block_id": "description_block", + "optional": True, + "element": { + "type": "plain_text_input", + "action_id": "description", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "What's happening?", + }, + }, + "label": {"type": "plain_text", "text": "Description"}, + }, + ] + + if impact_type_options: + blocks.append( + { + "type": "input", + "block_id": "impact_type_block", + "optional": True, + "element": { + "type": "multi_static_select", + "action_id": "impact_type_tags", + "placeholder": { + "type": "plain_text", + "text": "Select impact types", + }, + "options": impact_type_options, + }, + "label": {"type": "plain_text", "text": "Impact Type"}, + } + ) + + if affected_service_options: + blocks.append( + { + "type": "input", + "block_id": "affected_service_block", + "optional": True, + "element": { + "type": "multi_static_select", + "action_id": "affected_service_tags", + "placeholder": { + "type": "plain_text", + "text": "Select affected services", + }, + "options": affected_service_options, + }, + "label": {"type": "plain_text", "text": "Affected Service"}, + } + ) + + if affected_region_options: + blocks.append( + { + "type": "input", + "block_id": "affected_region_block", + "optional": True, + "element": { + "type": "multi_static_select", + "action_id": "affected_region_tags", + "placeholder": { + "type": "plain_text", + "text": "Select affected regions", + }, + "options": affected_region_options, + }, + "label": {"type": "plain_text", "text": "Affected Region"}, + } + ) + + blocks.append( + { + "type": "input", + "block_id": "private_block", + "optional": True, + "element": { + "type": "checkboxes", + "action_id": "is_private", + "options": [ + { + "text": {"type": "plain_text", "text": "Private incident"}, + "value": "private", + } + ], + }, + "label": {"type": "plain_text", "text": "Visibility"}, + } + ) + + return { + "type": "modal", + "callback_id": "new_incident_modal", + "title": {"type": "plain_text", "text": "New Incident"}, + "submit": {"type": "plain_text", "text": "Create"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": blocks, + } + + +def handle_new_command(ack: Any, body: dict, command: dict, respond: Any) -> None: + ack() + trigger_id = body.get("trigger_id") + if not trigger_id: + respond("Could not open modal — missing trigger_id.") + return + + from firetower.slack_app.bolt import bolt_app # noqa: PLC0415 + + bolt_app.client.views_open(trigger_id=trigger_id, view=_build_new_incident_modal()) + + +def handle_new_incident_submission( + ack: Any, body: dict, view: dict, client: Any +) -> None: + values = view.get("state", {}).get("values", {}) + + title = values.get("title_block", {}).get("title", {}).get("value", "") + severity = ( + values.get("severity_block", {}) + .get("severity", {}) + .get("selected_option", {}) + .get("value", "P2") + ) + description = ( + values.get("description_block", {}).get("description", {}).get("value") or "" + ) + + impact_type_selections = ( + values.get("impact_type_block", {}) + .get("impact_type_tags", {}) + .get("selected_options") + or [] + ) + impact_type_tags = [opt["value"] for opt in impact_type_selections] + + affected_service_selections = ( + values.get("affected_service_block", {}) + .get("affected_service_tags", {}) + .get("selected_options") + or [] + ) + affected_service_tags = [opt["value"] for opt in affected_service_selections] + + affected_region_selections = ( + values.get("affected_region_block", {}) + .get("affected_region_tags", {}) + .get("selected_options") + or [] + ) + affected_region_tags = [opt["value"] for opt in affected_region_selections] + + private_selections = ( + values.get("private_block", {}).get("is_private", {}).get("selected_options") + or [] + ) + is_private = any(opt.get("value") == "private" for opt in private_selections) + + slack_user_id = body.get("user", {}).get("id", "") + user = get_or_create_user_from_slack_id(slack_user_id) + if not user: + ack( + response_action="errors", + errors={"title_block": "Could not identify your Firetower account."}, + ) + return + + data = { + "title": title, + "severity": severity, + "description": description, + "captain": user.email, + "reporter": user.email, + "is_private": is_private, + } + if impact_type_tags: + data["impact_type_tags"] = impact_type_tags + if affected_service_tags: + data["affected_service_tags"] = affected_service_tags + if affected_region_tags: + data["affected_region_tags"] = affected_region_tags + + serializer = IncidentWriteSerializer(data=data) + if not serializer.is_valid(): + errors = {} + if "title" in serializer.errors: + errors["title_block"] = str(serializer.errors["title"][0]) + if "severity" in serializer.errors: + errors["severity_block"] = str(serializer.errors["severity"][0]) + if not errors: + first_key = next(iter(serializer.errors)) + errors["title_block"] = str(serializer.errors[first_key][0]) + ack(response_action="errors", errors=errors) + return + + ack() + + try: + incident = serializer.save() + except Exception: + logger.exception("Failed to create incident from Slack modal") + client.chat_postMessage( + channel=slack_user_id, + text=( + "Something went wrong creating your incident. " + "Please create it manually in Firetower and create a Slack channel, " + "then let #team-sre know." + ), + ) + return + + base_url = settings.FIRETOWER_BASE_URL + incident_url = f"{base_url}/incidents/{incident.incident_number}" + slack_link = incident.external_links_dict.get("slack", "") + + message = f"*{incident.incident_number}: {incident.title}* created!\n<{incident_url}|View in Firetower>" + if slack_link: + message += f"\n<{slack_link}|Slack channel>" + + client.chat_postMessage(channel=slack_user_id, text=message) diff --git a/src/firetower/slack_app/handlers/reopen.py b/src/firetower/slack_app/handlers/reopen.py new file mode 100644 index 00000000..c1f2e32e --- /dev/null +++ b/src/firetower/slack_app/handlers/reopen.py @@ -0,0 +1,29 @@ +import logging +from typing import Any + +from firetower.incidents.serializers import IncidentWriteSerializer +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + + +def handle_reopen_command(ack: Any, body: dict, command: dict, respond: Any) -> None: + ack() + channel_id = body.get("channel_id", "") + incident = get_incident_from_channel(channel_id) + if not incident: + respond("Could not find an incident associated with this channel.") + return + + if incident.status == "Active": + respond(f"{incident.incident_number} is already Active.") + return + + serializer = IncidentWriteSerializer( + instance=incident, data={"status": "Active"}, partial=True + ) + if serializer.is_valid(): + serializer.save() + respond(f"{incident.incident_number} has been reopened.") + else: + respond(f"Failed to reopen incident: {serializer.errors}") diff --git a/src/firetower/slack_app/handlers/resolved.py b/src/firetower/slack_app/handlers/resolved.py new file mode 100644 index 00000000..e8aee1d2 --- /dev/null +++ b/src/firetower/slack_app/handlers/resolved.py @@ -0,0 +1,186 @@ +import logging +from typing import Any + +from firetower.auth.models import ExternalProfileType +from firetower.auth.services import get_or_create_user_from_slack_id +from firetower.incidents.models import IncidentSeverity +from firetower.incidents.serializers import IncidentWriteSerializer +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + + +def _build_resolved_modal( + incident_number: str, + channel_id: str, + current_severity: str, + captain_slack_id: str | None, +) -> dict: + severity_options = [ + { + "text": {"type": "plain_text", "text": sev.label}, + "value": sev.value, + } + for sev in IncidentSeverity + ] + initial_severity = next( + (opt for opt in severity_options if opt["value"] == current_severity), + severity_options[2], + ) + + captain_element: dict = { + "type": "users_select", + "action_id": "captain_select", + "placeholder": {"type": "plain_text", "text": "Select incident captain"}, + } + if captain_slack_id: + captain_element["initial_user"] = captain_slack_id + + return { + "type": "modal", + "callback_id": "resolved_incident_modal", + "private_metadata": channel_id, + "title": {"type": "plain_text", "text": incident_number}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This incident has been contained! Please confirm the final severity and incident captain.", + }, + }, + { + "type": "input", + "block_id": "severity_block", + "element": { + "type": "static_select", + "action_id": "severity_select", + "options": severity_options, + "initial_option": initial_severity, + }, + "label": {"type": "plain_text", "text": "Severity"}, + }, + { + "type": "input", + "block_id": "captain_block", + "element": captain_element, + "label": {"type": "plain_text", "text": "Incident Captain"}, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "The incident captain is responsible for driving the postmortem.", + } + ], + }, + ], + } + + +def handle_resolved_command(ack: Any, body: dict, command: dict, respond: Any) -> None: + ack() + channel_id = body.get("channel_id", "") + incident = get_incident_from_channel(channel_id) + if not incident: + respond("Could not find an incident associated with this channel.") + return + + trigger_id = body.get("trigger_id") + if not trigger_id: + respond("Could not open modal — missing trigger_id.") + return + + captain_slack_id = None + if incident.captain: + slack_profile = incident.captain.external_profiles.filter( + type=ExternalProfileType.SLACK + ).first() + if slack_profile: + captain_slack_id = slack_profile.external_id + + from firetower.slack_app.bolt import bolt_app # noqa: PLC0415 + + bolt_app.client.views_open( + trigger_id=trigger_id, + view=_build_resolved_modal( + incident.incident_number, + channel_id, + incident.severity, + captain_slack_id, + ), + ) + + +def handle_resolved_submission(ack: Any, body: dict, view: dict, client: Any) -> None: + values = view.get("state", {}).get("values", {}) + channel_id = view.get("private_metadata", "") + + severity = ( + values.get("severity_block", {}) + .get("severity_select", {}) + .get("selected_option", {}) + .get("value", "") + ) + captain_slack_id = ( + values.get("captain_block", {}).get("captain_select", {}).get("selected_user") + ) + + if not captain_slack_id: + ack( + response_action="errors", + errors={"captain_block": "An incident captain is required."}, + ) + return + + ack() + + incident = get_incident_from_channel(channel_id) + if not incident: + logger.error("Resolved submission: no incident for channel %s", channel_id) + return + + captain_user = get_or_create_user_from_slack_id(captain_slack_id) + if not captain_user: + logger.error( + "Could not resolve Slack user %s to a Firetower user", captain_slack_id + ) + client.chat_postMessage( + channel=channel_id, + text="Failed to resolve the selected captain to a Firetower user.", + ) + return + + if severity in ("P0", "P1", "P2"): + target_status = "Postmortem" + else: + target_status = "Done" + + data: dict[str, Any] = { + "status": target_status, + "severity": severity, + "captain": captain_user.email, + } + + serializer = IncidentWriteSerializer(instance=incident, data=data, partial=True) + if not serializer.is_valid(): + logger.error("Resolved update failed: %s", serializer.errors) + client.chat_postMessage( + channel=channel_id, + text=f"Failed to resolve incident: {serializer.errors}", + ) + return + serializer.save() + + client.chat_postMessage( + channel=channel_id, + text=( + f"*{incident.incident_number} marked as {target_status}*\n" + f"Severity: {severity} | Captain: {captain_user.get_full_name()}" + ), + ) + + # TODO: Postmortem doc generation will be added in RELENG-466 (Notion integration) diff --git a/src/firetower/slack_app/handlers/severity.py b/src/firetower/slack_app/handlers/severity.py new file mode 100644 index 00000000..8c7f8d26 --- /dev/null +++ b/src/firetower/slack_app/handlers/severity.py @@ -0,0 +1,38 @@ +import logging +from typing import Any + +from firetower.incidents.models import IncidentSeverity +from firetower.incidents.serializers import IncidentWriteSerializer +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + +VALID_SEVERITIES = {s.value.lower(): s.value for s in IncidentSeverity} + + +def handle_severity_command( + ack: Any, body: dict, command: dict, respond: Any, new_severity: str +) -> None: + ack() + channel_id = body.get("channel_id", "") + incident = get_incident_from_channel(channel_id) + if not incident: + respond("Could not find an incident associated with this channel.") + return + + normalized = VALID_SEVERITIES.get(new_severity.lower()) + if not normalized: + valid = ", ".join(VALID_SEVERITIES.values()) + respond(f"Invalid severity `{new_severity}`. Must be one of: {valid}") + return + + serializer = IncidentWriteSerializer( + instance=incident, data={"severity": normalized}, partial=True + ) + if serializer.is_valid(): + serializer.save() + respond(f"{incident.incident_number} severity updated to {normalized}.") + else: + respond(f"Failed to update severity: {serializer.errors}") + + # TODO: P0/P1 upgrade handling (PagerDuty, status channel) will be added in RELENG-465 diff --git a/src/firetower/slack_app/handlers/subject.py b/src/firetower/slack_app/handlers/subject.py new file mode 100644 index 00000000..d7439710 --- /dev/null +++ b/src/firetower/slack_app/handlers/subject.py @@ -0,0 +1,27 @@ +import logging +from typing import Any + +from firetower.incidents.serializers import IncidentWriteSerializer +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + + +def handle_subject_command( + ack: Any, body: dict, command: dict, respond: Any, new_subject: str +) -> None: + ack() + channel_id = body.get("channel_id", "") + incident = get_incident_from_channel(channel_id) + if not incident: + respond("Could not find an incident associated with this channel.") + return + + serializer = IncidentWriteSerializer( + instance=incident, data={"title": new_subject}, partial=True + ) + if serializer.is_valid(): + serializer.save() + respond(f"{incident.incident_number} subject updated to: {new_subject}") + else: + respond(f"Failed to update subject: {serializer.errors}") diff --git a/src/firetower/slack_app/handlers/utils.py b/src/firetower/slack_app/handlers/utils.py new file mode 100644 index 00000000..de3d576a --- /dev/null +++ b/src/firetower/slack_app/handlers/utils.py @@ -0,0 +1,11 @@ +from firetower.incidents.models import ExternalLink, ExternalLinkType, Incident + + +def get_incident_from_channel(channel_id: str) -> Incident | None: + link = ExternalLink.objects.filter( + type=ExternalLinkType.SLACK, + url__endswith=channel_id, + ).first() + if link: + return link.incident + return None diff --git a/src/firetower/slack_app/management/__init__.py b/src/firetower/slack_app/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/slack_app/management/commands/__init__.py b/src/firetower/slack_app/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/slack_app/management/commands/run_slack_bot.py b/src/firetower/slack_app/management/commands/run_slack_bot.py new file mode 100644 index 00000000..0604282d --- /dev/null +++ b/src/firetower/slack_app/management/commands/run_slack_bot.py @@ -0,0 +1,41 @@ +import logging +import os +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +from django.conf import settings +from django.core.management.base import BaseCommand +from slack_bolt.adapter.socket_mode import SocketModeHandler + +from firetower.slack_app.bolt import bolt_app + +logger = logging.getLogger(__name__) + + +class _HealthHandler(BaseHTTPRequestHandler): + def do_GET(self, *args: Any) -> None: + self.send_response(200) + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: + pass + + +def _start_health_server() -> None: + port = int(os.environ.get("PORT", "8080")) + server = HTTPServer(("0.0.0.0", port), _HealthHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + logger.info("Health check server listening on port %d", port) + + +class Command(BaseCommand): + help = "Start the Slack bot in Socket Mode" + + def handle(self, *args: Any, **options: Any) -> None: + _start_health_server() + app_token = settings.SLACK["APP_TOKEN"] + handler = SocketModeHandler(app=bolt_app, app_token=app_token) + logger.info("Starting Slack bot in Socket Mode") + handler.start() diff --git a/src/firetower/slack_app/tests/__init__.py b/src/firetower/slack_app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/slack_app/tests/conftest.py b/src/firetower/slack_app/tests/conftest.py new file mode 100644 index 00000000..9e762fff --- /dev/null +++ b/src/firetower/slack_app/tests/conftest.py @@ -0,0 +1,19 @@ +from unittest.mock import patch + +from slack_sdk.web import WebClient + +mock_auth = patch.object( + WebClient, + "auth_test", + return_value={ + "ok": True, + "user_id": "U0000", + "team_id": "T0000", + "bot_id": "B0000", + }, +) +mock_auth.start() + + +def pytest_sessionfinish(session, exitstatus): + mock_auth.stop() diff --git a/src/firetower/slack_app/tests/test_channel_commands.py b/src/firetower/slack_app/tests/test_channel_commands.py new file mode 100644 index 00000000..3b10727c --- /dev/null +++ b/src/firetower/slack_app/tests/test_channel_commands.py @@ -0,0 +1,537 @@ +from unittest.mock import MagicMock, patch + +import pytest +from django.contrib.auth.models import User + +from firetower.auth.models import ExternalProfile, ExternalProfileType +from firetower.incidents.models import ( + ExternalLink, + ExternalLinkType, + Incident, + IncidentSeverity, + IncidentStatus, +) +from firetower.slack_app.bolt import handle_inc +from firetower.slack_app.handlers.mitigated import ( + handle_mitigated_command, + handle_mitigated_submission, +) +from firetower.slack_app.handlers.reopen import handle_reopen_command +from firetower.slack_app.handlers.resolved import ( + handle_resolved_command, + handle_resolved_submission, +) +from firetower.slack_app.handlers.severity import handle_severity_command +from firetower.slack_app.handlers.subject import handle_subject_command +from firetower.slack_app.handlers.utils import get_incident_from_channel + + +@pytest.fixture +def user(db): + u = User.objects.create_user( + username="test@example.com", + email="test@example.com", + first_name="Test", + last_name="User", + ) + ExternalProfile.objects.create( + user=u, + type=ExternalProfileType.SLACK, + external_id="U_CAPTAIN", + ) + return u + + +@pytest.fixture +def incident(user): + inc = Incident( + title="Test Incident", + severity=IncidentSeverity.P2, + status=IncidentStatus.ACTIVE, + captain=user, + reporter=user, + ) + inc.save() + ExternalLink.objects.create( + incident=inc, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C_TEST_CHANNEL", + ) + return inc + + +CHANNEL_ID = "C_TEST_CHANNEL" + + +class TestGetIncidentFromChannel: + def test_returns_incident(self, incident): + result = get_incident_from_channel(CHANNEL_ID) + assert result == incident + + def test_returns_none_for_unknown_channel(self, db): + result = get_incident_from_channel("C_UNKNOWN") + assert result is None + + def test_returns_none_when_no_incidents(self, db): + result = get_incident_from_channel(CHANNEL_ID) + assert result is None + + +@pytest.mark.django_db +class TestMitigatedCommand: + @patch("firetower.slack_app.bolt.bolt_app") + def test_opens_modal(self, mock_bolt_app, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID, "trigger_id": "T12345"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_mitigated_command(ack, body, command, respond) + + ack.assert_called_once() + mock_bolt_app.client.views_open.assert_called_once() + view = mock_bolt_app.client.views_open.call_args[1]["view"] + assert view["callback_id"] == "mitigated_incident_modal" + assert view["private_metadata"] == CHANNEL_ID + assert incident.incident_number in view["title"]["text"] + + def test_no_incident_responds_error(self, db): + ack = MagicMock() + body = {"channel_id": "C_UNKNOWN", "trigger_id": "T12345"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_mitigated_command(ack, body, command, respond) + + ack.assert_called_once() + respond.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestMitigatedSubmission: + @patch("firetower.incidents.serializers.on_status_changed") + @patch("firetower.incidents.serializers.on_title_changed") + def test_transitions_to_mitigated( + self, mock_title_hook, mock_status_hook, incident + ): + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_CAPTAIN"}} + view = { + "private_metadata": CHANNEL_ID, + "state": { + "values": { + "impact_block": {"impact_update": {"value": "Reduced impact"}}, + "todo_block": {"todo_update": {"value": "Monitor overnight"}}, + } + }, + } + + handle_mitigated_submission(ack, body, view, client) + + ack.assert_called_once() + incident.refresh_from_db() + assert incident.status == IncidentStatus.MITIGATED + assert "Reduced impact" in incident.description + assert "Monitor overnight" in incident.description + client.chat_postMessage.assert_called_once() + msg = client.chat_postMessage.call_args[1]["text"] + assert "Mitigated" in msg + + def test_missing_incident_does_not_crash(self, db): + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_CAPTAIN"}} + view = { + "private_metadata": "C_NONEXISTENT", + "state": { + "values": { + "impact_block": {"impact_update": {"value": "x"}}, + "todo_block": {"todo_update": {"value": "y"}}, + } + }, + } + + handle_mitigated_submission(ack, body, view, client) + + ack.assert_called_once() + client.chat_postMessage.assert_not_called() + + +@pytest.mark.django_db +class TestResolvedCommand: + @patch("firetower.slack_app.bolt.bolt_app") + def test_opens_modal(self, mock_bolt_app, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID, "trigger_id": "T12345"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_resolved_command(ack, body, command, respond) + + ack.assert_called_once() + mock_bolt_app.client.views_open.assert_called_once() + view = mock_bolt_app.client.views_open.call_args[1]["view"] + assert view["callback_id"] == "resolved_incident_modal" + + def test_no_incident_responds_error(self, db): + ack = MagicMock() + body = {"channel_id": "C_UNKNOWN", "trigger_id": "T12345"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_resolved_command(ack, body, command, respond) + + ack.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestResolvedSubmission: + @patch("firetower.incidents.serializers.on_status_changed") + @patch("firetower.incidents.serializers.on_severity_changed") + @patch("firetower.incidents.serializers.on_captain_changed") + @patch("firetower.incidents.serializers.on_title_changed") + @patch("firetower.slack_app.handlers.resolved.get_or_create_user_from_slack_id") + def test_p1_goes_to_postmortem( + self, + mock_get_user, + mock_title_hook, + mock_captain_hook, + mock_sev_hook, + mock_status_hook, + user, + incident, + ): + mock_get_user.return_value = user + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_CAPTAIN"}} + view = { + "private_metadata": CHANNEL_ID, + "state": { + "values": { + "severity_block": { + "severity_select": {"selected_option": {"value": "P1"}} + }, + "captain_block": {"captain_select": {"selected_user": "U_CAPTAIN"}}, + } + }, + } + + handle_resolved_submission(ack, body, view, client) + + ack.assert_called_once_with() + incident.refresh_from_db() + assert incident.status == IncidentStatus.POSTMORTEM + assert incident.severity == IncidentSeverity.P1 + client.chat_postMessage.assert_called_once() + assert "Postmortem" in client.chat_postMessage.call_args[1]["text"] + + @patch("firetower.incidents.serializers.on_status_changed") + @patch("firetower.incidents.serializers.on_title_changed") + @patch("firetower.slack_app.handlers.resolved.get_or_create_user_from_slack_id") + def test_p4_goes_to_done( + self, mock_get_user, mock_title_hook, mock_status_hook, user, incident + ): + mock_get_user.return_value = user + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_CAPTAIN"}} + view = { + "private_metadata": CHANNEL_ID, + "state": { + "values": { + "severity_block": { + "severity_select": {"selected_option": {"value": "P4"}} + }, + "captain_block": {"captain_select": {"selected_user": "U_CAPTAIN"}}, + } + }, + } + + handle_resolved_submission(ack, body, view, client) + + ack.assert_called_once_with() + incident.refresh_from_db() + assert incident.status == IncidentStatus.DONE + + def test_missing_captain_returns_error(self, incident): + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_CAPTAIN"}} + view = { + "private_metadata": CHANNEL_ID, + "state": { + "values": { + "severity_block": { + "severity_select": {"selected_option": {"value": "P2"}} + }, + "captain_block": {"captain_select": {"selected_user": None}}, + } + }, + } + + handle_resolved_submission(ack, body, view, client) + + ack.assert_called_once() + call_kwargs = ack.call_args[1] + assert call_kwargs["response_action"] == "errors" + assert "captain" in str(call_kwargs["errors"]).lower() + + +@pytest.mark.django_db +class TestReopenCommand: + @patch("firetower.incidents.serializers.on_status_changed") + @patch("firetower.incidents.serializers.on_title_changed") + def test_reopens_mitigated_incident( + self, mock_title_hook, mock_status_hook, incident + ): + incident.status = IncidentStatus.MITIGATED + incident.save() + + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_reopen_command(ack, body, command, respond) + + ack.assert_called_once() + incident.refresh_from_db() + assert incident.status == IncidentStatus.ACTIVE + assert "reopened" in respond.call_args[0][0] + + def test_already_active_responds(self, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_reopen_command(ack, body, command, respond) + + ack.assert_called_once() + assert "already Active" in respond.call_args[0][0] + + def test_no_incident_responds_error(self, db): + ack = MagicMock() + body = {"channel_id": "C_UNKNOWN"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_reopen_command(ack, body, command, respond) + + ack.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestSeverityCommand: + @patch("firetower.incidents.serializers.on_severity_changed") + @patch("firetower.incidents.serializers.on_title_changed") + def test_changes_severity(self, mock_title_hook, mock_sev_hook, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_severity_command(ack, body, command, respond, new_severity="P0") + + ack.assert_called_once() + incident.refresh_from_db() + assert incident.severity == IncidentSeverity.P0 + assert "P0" in respond.call_args[0][0] + + def test_invalid_severity(self, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_severity_command(ack, body, command, respond, new_severity="P9") + + ack.assert_called_once() + assert "Invalid severity" in respond.call_args[0][0] + + def test_case_insensitive(self, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + with patch("firetower.incidents.serializers.on_severity_changed"): + handle_severity_command(ack, body, command, respond, new_severity="p1") + + incident.refresh_from_db() + assert incident.severity == IncidentSeverity.P1 + + def test_no_incident_responds_error(self, db): + ack = MagicMock() + body = {"channel_id": "C_UNKNOWN"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_severity_command(ack, body, command, respond, new_severity="P0") + + ack.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestSubjectCommand: + @patch("firetower.incidents.serializers.on_title_changed") + def test_updates_title(self, mock_title_hook, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_subject_command(ack, body, command, respond, new_subject="New Title") + + ack.assert_called_once() + incident.refresh_from_db() + assert incident.title == "New Title" + assert "New Title" in respond.call_args[0][0] + + def test_no_incident_responds_error(self, db): + ack = MagicMock() + body = {"channel_id": "C_UNKNOWN"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_subject_command(ack, body, command, respond, new_subject="New Title") + + ack.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestRouting: + @patch("firetower.slack_app.bolt.statsd") + def test_mitigated_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "mitigated", "channel_id": CHANNEL_ID, "trigger_id": "T123"} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_mitigated_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_mit_alias_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "mit", "channel_id": CHANNEL_ID, "trigger_id": "T123"} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_mitigated_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_resolved_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "resolved", "channel_id": CHANNEL_ID, "trigger_id": "T123"} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_resolved_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_fixed_alias_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "fixed", "channel_id": CHANNEL_ID, "trigger_id": "T123"} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_resolved_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_reopen_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "reopen", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_reopen_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_severity_routes_with_arg(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "severity P0", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_severity_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + assert mock_handler.call_args[1]["new_severity"] == "P0" + + @patch("firetower.slack_app.bolt.statsd") + def test_severity_no_arg_shows_usage(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "severity", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + assert "Usage" in respond.call_args[0][0] + + @patch("firetower.slack_app.bolt.statsd") + def test_sev_alias_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "sev P1", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_severity_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_subject_routes_with_arg(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "subject New Title Here", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_subject_command") as mock_handler: + handle_inc(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + assert mock_handler.call_args[1]["new_subject"] == "New Title Here" + + @patch("firetower.slack_app.bolt.statsd") + def test_subject_no_arg_shows_usage(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "subject", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + assert "Usage" in respond.call_args[0][0] + + @patch("firetower.slack_app.bolt.statsd") + def test_metrics_for_known_subcommands(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "reopen", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_reopen_command"): + handle_inc(ack=ack, body=body, command=command, respond=respond) + + mock_statsd.increment.assert_any_call( + "slack_app.commands.submitted", tags=["subcommand:reopen"] + ) diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py new file mode 100644 index 00000000..3b20b403 --- /dev/null +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -0,0 +1,280 @@ +from unittest.mock import MagicMock, call, patch + +import pytest +from django.contrib.auth.models import User + +from firetower.auth.models import ExternalProfile, ExternalProfileType +from firetower.incidents.models import Incident, IncidentSeverity +from firetower.slack_app.bolt import handle_inc +from firetower.slack_app.handlers.new_incident import ( + handle_new_command, + handle_new_incident_submission, +) + + +@pytest.mark.django_db +class TestHandleInc: + def _make_body(self, text="", command="/ft"): + return {"text": text, "command": command} + + def _make_command(self, command="/ft", text=""): + return {"command": command, "text": text} + + @patch("firetower.slack_app.bolt.statsd") + def test_help_returns_help_text(self, mock_statsd): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="help") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + respond.assert_called_once() + response_text = respond.call_args[0][0] + assert "Firetower Slack App" in response_text + assert "/ft help" in response_text + + @patch("firetower.slack_app.bolt.statsd") + def test_empty_text_returns_help(self, mock_statsd): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + respond.assert_called_once() + response_text = respond.call_args[0][0] + assert "Firetower Slack App" in response_text + + @patch("firetower.slack_app.bolt.statsd") + def test_unknown_subcommand_returns_error(self, mock_statsd): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="unknown") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + respond.assert_called_once() + response_text = respond.call_args[0][0] + assert "Unknown command" in response_text + assert "/ft unknown" in response_text + + @patch("firetower.slack_app.bolt.statsd") + def test_help_uses_testinc_command(self, mock_statsd): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="help", command="/ft-test") + command = self._make_command(command="/ft-test") + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + response_text = respond.call_args[0][0] + assert "/ft-test help" in response_text + + @patch("firetower.slack_app.bolt.statsd") + def test_emits_submitted_and_completed_metrics(self, mock_statsd): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="help") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + mock_statsd.increment.assert_has_calls( + [ + call("slack_app.commands.submitted", tags=["subcommand:help"]), + call("slack_app.commands.completed", tags=["subcommand:help"]), + ] + ) + + @patch("firetower.slack_app.bolt.statsd") + def test_emits_failed_metric_on_error(self, mock_statsd): + ack = MagicMock() + respond = MagicMock(side_effect=RuntimeError("boom")) + body = self._make_body(text="help") + command = self._make_command() + + with pytest.raises(RuntimeError): + handle_inc(ack=ack, body=body, command=command, respond=respond) + + mock_statsd.increment.assert_any_call( + "slack_app.commands.submitted", tags=["subcommand:help"] + ) + mock_statsd.increment.assert_any_call( + "slack_app.commands.failed", tags=["subcommand:help"] + ) + + @patch("firetower.slack_app.bolt.bolt_app") + @patch("firetower.slack_app.bolt.statsd") + def test_new_subcommand_routes_correctly(self, mock_statsd, mock_bolt_app): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="new") + body["trigger_id"] = "T12345" + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + mock_bolt_app.client.views_open.assert_called_once() + + +@pytest.mark.django_db +class TestNewIncidentModal: + @patch("firetower.slack_app.bolt.bolt_app") + def test_new_opens_modal(self, mock_bolt_app): + ack = MagicMock() + body = {"trigger_id": "T12345"} + command = {"text": "new"} + respond = MagicMock() + + handle_new_command(ack, body, command, respond) + + ack.assert_called_once() + mock_bolt_app.client.views_open.assert_called_once() + view = mock_bolt_app.client.views_open.call_args[1]["view"] + assert view["callback_id"] == "new_incident_modal" + assert view["type"] == "modal" + + +@pytest.mark.django_db +class TestNewIncidentSubmission: + def setup_method(self): + self.user = User.objects.create_user( + username="test@example.com", + email="test@example.com", + first_name="Test", + last_name="User", + ) + ExternalProfile.objects.create( + user=self.user, + type=ExternalProfileType.SLACK, + external_id="U_TEST", + ) + + @patch("firetower.incidents.serializers.on_incident_created") + @patch("firetower.slack_app.handlers.new_incident.get_or_create_user_from_slack_id") + def test_creates_incident(self, mock_get_user, mock_hook): + mock_get_user.return_value = self.user + + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_TEST"}} + view = { + "state": { + "values": { + "title_block": {"title": {"value": "Test Incident"}}, + "severity_block": { + "severity": { + "selected_option": {"value": "P1"}, + } + }, + "description_block": {"description": {"value": "Description"}}, + "private_block": {"is_private": {"selected_options": []}}, + } + } + } + + handle_new_incident_submission(ack, body, view, client) + + ack.assert_called_once_with() + incident = Incident.objects.get(title="Test Incident") + assert incident.severity == IncidentSeverity.P1 + assert incident.captain == self.user + assert incident.reporter == self.user + client.chat_postMessage.assert_called_once() + + @patch("firetower.slack_app.handlers.new_incident.get_or_create_user_from_slack_id") + def test_validation_error(self, mock_get_user): + mock_get_user.return_value = self.user + + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_TEST"}} + view = { + "state": { + "values": { + "title_block": {"title": {"value": ""}}, + "severity_block": { + "severity": { + "selected_option": {"value": "P1"}, + } + }, + "description_block": {"description": {"value": ""}}, + "private_block": {"is_private": {"selected_options": []}}, + } + } + } + + handle_new_incident_submission(ack, body, view, client) + + ack.assert_called_once() + call_kwargs = ack.call_args[1] + assert call_kwargs["response_action"] == "errors" + client.chat_postMessage.assert_not_called() + + @patch("firetower.slack_app.handlers.new_incident.get_or_create_user_from_slack_id") + def test_unknown_user_returns_error(self, mock_get_user): + mock_get_user.return_value = None + + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_UNKNOWN"}} + view = { + "state": { + "values": { + "title_block": {"title": {"value": "Test"}}, + "severity_block": { + "severity": {"selected_option": {"value": "P1"}} + }, + "description_block": {"description": {"value": ""}}, + "private_block": {"is_private": {"selected_options": []}}, + } + } + } + + handle_new_incident_submission(ack, body, view, client) + + ack.assert_called_once() + call_kwargs = ack.call_args[1] + assert call_kwargs["response_action"] == "errors" + + @patch( + "firetower.incidents.serializers.on_incident_created", + side_effect=RuntimeError("boom"), + ) + @patch("firetower.slack_app.handlers.new_incident.get_or_create_user_from_slack_id") + def test_save_failure_sends_error_dm(self, mock_get_user, mock_hook): + mock_get_user.return_value = self.user + + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_TEST"}} + view = { + "state": { + "values": { + "title_block": {"title": {"value": "Test Incident"}}, + "severity_block": { + "severity": { + "selected_option": {"value": "P1"}, + } + }, + "description_block": {"description": {"value": "Description"}}, + "private_block": {"is_private": {"selected_options": []}}, + } + } + } + + handle_new_incident_submission(ack, body, view, client) + + ack.assert_called_once_with() + client.chat_postMessage.assert_called_once() + msg = client.chat_postMessage.call_args[1]["text"] + assert "Something went wrong" in msg + assert "#team-sre" in msg diff --git a/uv.lock b/uv.lock index 644d3507..ccbb18db 100644 --- a/uv.lock +++ b/uv.lock @@ -528,6 +528,7 @@ dependencies = [ { name = "jira" }, { name = "psycopg", extra = ["binary"] }, { name = "pyserde", extra = ["toml"] }, + { name = "slack-bolt" }, { name = "slack-sdk" }, ] @@ -562,6 +563,7 @@ requires-dist = [ { name = "jira", specifier = ">=3.5.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.11" }, { name = "pyserde", extras = ["toml"], specifier = ">=0.28.0" }, + { name = "slack-bolt", specifier = ">=1.27.0" }, { name = "slack-sdk", specifier = ">=3.31.0" }, ] @@ -1294,13 +1296,25 @@ django = [ { name = "django" }, ] +[[package]] +name = "slack-bolt" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/28/50ed0b86e48b48e6ddcc71de93b91c8ac14a55d1249e4bff0586494a2f90/slack_bolt-1.27.0.tar.gz", hash = "sha256:3db91d64e277e176a565c574ae82748aa8554f19e41a4fceadca4d65374ce1e0", size = 129101, upload-time = "2025-11-13T20:17:46.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a8/1acb355759747ba4da5f45c1a33d641994b9e04b914908c9434f18bd97e8/slack_bolt-1.27.0-py2.py3-none-any.whl", hash = "sha256:c43c94bf34740f2adeb9b55566c83f1e73fed6ba2878bd346cdfd6fd8ad22360", size = 230428, upload-time = "2025-11-13T20:17:45.465Z" }, +] + [[package]] name = "slack-sdk" -version = "3.37.0" +version = "3.40.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/c2/0a174a155623d7dc3ed4d1360cdf755590acdc2c3fc9ce0d2340f468909f/slack_sdk-3.37.0.tar.gz", hash = "sha256:242d6cffbd9e843af807487ff04853189b812081aeaa22f90a8f159f20220ed9", size = 241612, upload-time = "2025-10-06T23:07:20.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/fd/a502ee24d8c7d12a8f749878ae0949b8eeb50aeac22dc5a613d417a256d0/slack_sdk-3.37.0-py2.py3-none-any.whl", hash = "sha256:e108a0836eafda74d8a95e76c12c2bcb010e645d504d8497451e4c7ebb229c87", size = 302751, upload-time = "2025-10-06T23:07:19.542Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, ] [[package]]