From 01c2052b09fe054b8f15a4785981d7fea5d4fa17 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Feb 2026 17:37:50 -0500 Subject: [PATCH 01/49] Add signing_secret to Slack config --- config.example.toml | 1 + src/firetower/config.py | 2 ++ src/firetower/settings.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/config.example.toml b/config.example.toml index c981fa0a..4d4d74f7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -21,6 +21,7 @@ bot_token = "" team_id = "" participant_sync_throttle_seconds = 300 app_token = "" +signing_secret = "" incident_feed_channel_id = "" always_invited_ids = [] incident_guide_message = "This is the message posted whenever a new incident slack channel is created." diff --git a/src/firetower/config.py b/src/firetower/config.py index 7f8234f7..ce56c29e 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -40,6 +40,7 @@ class SlackConfig: team_id: str participant_sync_throttle_seconds: int app_token: str + signing_secret: str incident_feed_channel_id: str = "" always_invited_ids: list[str] = field(default_factory=list) incident_guide_message: str = "" @@ -124,6 +125,7 @@ def __init__(self) -> None: team_id="", participant_sync_throttle_seconds=0, app_token="", + signing_secret="", incident_feed_channel_id="", always_invited_ids=[], incident_guide_message="", diff --git a/src/firetower/settings.py b/src/firetower/settings.py index b224befc..f28b9ffa 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -228,6 +228,7 @@ class SlackSettings(TypedDict): BOT_TOKEN: str TEAM_ID: str APP_TOKEN: str + SIGNING_SECRET: str INCIDENT_FEED_CHANNEL_ID: str ALWAYS_INVITED_IDS: list[str] INCIDENT_GUIDE_MESSAGE: str @@ -237,6 +238,7 @@ class SlackSettings(TypedDict): "BOT_TOKEN": config.slack.bot_token, "TEAM_ID": config.slack.team_id, "APP_TOKEN": config.slack.app_token, + "SIGNING_SECRET": config.slack.signing_secret, "INCIDENT_FEED_CHANNEL_ID": config.slack.incident_feed_channel_id, "ALWAYS_INVITED_IDS": config.slack.always_invited_ids, "INCIDENT_GUIDE_MESSAGE": config.slack.incident_guide_message, From f12444162cd116045d5e7562da0ce098312c974c Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Feb 2026 17:38:51 -0500 Subject: [PATCH 02/49] Add slack_app Django app with Bolt wiring and /inc help command --- src/firetower/slack_app/authentication.py | 67 +++++++++++++ src/firetower/slack_app/block_kits.py | 0 .../slack_app/tests/test_handlers.py | 64 +++++++++++++ src/firetower/slack_app/tests/test_views.py | 93 +++++++++++++++++++ src/firetower/slack_app/urls.py | 7 ++ src/firetower/slack_app/views.py | 24 +++++ src/firetower/urls.py | 1 + 7 files changed, 256 insertions(+) create mode 100644 src/firetower/slack_app/authentication.py create mode 100644 src/firetower/slack_app/block_kits.py create mode 100644 src/firetower/slack_app/tests/test_handlers.py create mode 100644 src/firetower/slack_app/tests/test_views.py create mode 100644 src/firetower/slack_app/urls.py create mode 100644 src/firetower/slack_app/views.py diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py new file mode 100644 index 00000000..d8ee2c0f --- /dev/null +++ b/src/firetower/slack_app/authentication.py @@ -0,0 +1,67 @@ +import hashlib +import hmac +import time + +from django.conf import settings +from django.contrib.auth import get_user_model +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.request import Request + +User = get_user_model() + +SERVICE_USERNAME = "firetower-slack-app" + + +class SlackSigningSecretAuthentication(BaseAuthentication): + """ + DRF authentication class that verifies Slack request signatures. + + Validates the X-Slack-Signature header using HMAC-SHA256 with the + configured signing secret. On success, returns a service user. + """ + + MAX_TIMESTAMP_AGE_SECONDS = 120 + + def authenticate(self, request: Request) -> tuple | None: + django_request = request._request + + timestamp = django_request.META.get("HTTP_X_SLACK_REQUEST_TIMESTAMP") + signature = django_request.META.get("HTTP_X_SLACK_SIGNATURE") + + if not timestamp or not signature: + return None + + try: + ts = int(timestamp) + except ValueError: + raise AuthenticationFailed("Invalid timestamp") + + if abs(time.time() - ts) > self.MAX_TIMESTAMP_AGE_SECONDS: + raise AuthenticationFailed("Request timestamp too old") + + signing_secret = settings.SLACK.get("SIGNING_SECRET", "") + raw_body = django_request.body + sig_basestring = f"v0:{timestamp}:{raw_body.decode('utf-8')}" + + computed = ( + "v0=" + + hmac.new( + signing_secret.encode("utf-8"), + sig_basestring.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + ) + + if not hmac.compare_digest(computed, signature): + raise AuthenticationFailed("Invalid signature") + + user, _ = User.objects.get_or_create( + username=SERVICE_USERNAME, + defaults={"is_active": True}, + ) + if not user.has_usable_password(): + user.set_unusable_password() + user.save(update_fields=["password"]) + + return (user, None) diff --git a/src/firetower/slack_app/block_kits.py b/src/firetower/slack_app/block_kits.py new file mode 100644 index 00000000..e69de29b 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..845a0f73 --- /dev/null +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -0,0 +1,64 @@ +from unittest.mock import MagicMock + +from firetower.slack_app.bolt import handle_inc + + +class TestHandleInc: + def _make_body(self, text="", command="/inc"): + return {"text": text, "command": command} + + def _make_command(self, command="/inc", text=""): + return {"command": command, "text": text} + + def test_help_returns_help_text(self): + 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 Incident Bot" in response_text + assert "/inc help" in response_text + + def test_empty_text_returns_help(self): + 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 Incident Bot" in response_text + + def test_unknown_subcommand_returns_error(self): + 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 "/inc unknown" in response_text + + def test_help_uses_testinc_command(self): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="help", command="/testinc") + command = self._make_command(command="/testinc") + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + response_text = respond.call_args[0][0] + assert "/testinc help" in response_text diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py new file mode 100644 index 00000000..8e2d14f0 --- /dev/null +++ b/src/firetower/slack_app/tests/test_views.py @@ -0,0 +1,93 @@ +import hashlib +import hmac +import time +from unittest.mock import patch + +import pytest +from django.conf import settings +from django.http import HttpResponse +from rest_framework.test import APIClient + + +@pytest.mark.django_db +class TestSlackEventsEndpoint: + def setup_method(self): + self.client = APIClient() + self.url = "/slack/events" + self.signing_secret = settings.SLACK["SIGNING_SECRET"] + + def _sign_request(self, body: str, timestamp: str | None = None): + ts = timestamp or str(int(time.time())) + sig_basestring = f"v0:{ts}:{body}" + signature = ( + "v0=" + + hmac.new( + self.signing_secret.encode("utf-8"), + sig_basestring.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + ) + return ts, signature + + def test_missing_auth_headers_returns_403(self): + response = self.client.post( + self.url, + data="command=/inc&text=help", + content_type="application/x-www-form-urlencoded", + ) + assert response.status_code == 403 + + def test_invalid_signature_returns_403(self): + ts = str(int(time.time())) + response = self.client.post( + self.url, + data="command=/inc&text=help", + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, + HTTP_X_SLACK_SIGNATURE="v0=invalidsignature", + ) + assert response.status_code == 403 + + def test_expired_timestamp_returns_403(self): + old_ts = str(int(time.time()) - 300) + body = "command=/inc&text=help" + _, signature = self._sign_request(body, old_ts) + response = self.client.post( + self.url, + data=body, + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=old_ts, + HTTP_X_SLACK_SIGNATURE=signature, + ) + assert response.status_code == 403 + + @patch("firetower.slack_app.views.handler") + def test_valid_signature_returns_200(self, mock_handler): + mock_handler.handle.return_value = HttpResponse(status=200) + + body = "command=/inc&text=help" + ts, signature = self._sign_request(body) + response = self.client.post( + self.url, + data=body, + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, + HTTP_X_SLACK_SIGNATURE=signature, + ) + assert response.status_code == 200 + mock_handler.handle.assert_called_once() + + @patch("firetower.slack_app.views.handler") + def test_csrf_not_enforced(self, mock_handler): + mock_handler.handle.return_value = HttpResponse(status=200) + + body = "command=/inc&text=help" + ts, signature = self._sign_request(body) + response = self.client.post( + self.url, + data=body, + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, + HTTP_X_SLACK_SIGNATURE=signature, + ) + assert response.status_code == 200 diff --git a/src/firetower/slack_app/urls.py b/src/firetower/slack_app/urls.py new file mode 100644 index 00000000..d4b58a4f --- /dev/null +++ b/src/firetower/slack_app/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from firetower.slack_app.views import slack_events + +urlpatterns = [ + path("events", slack_events), +] diff --git a/src/firetower/slack_app/views.py b/src/firetower/slack_app/views.py new file mode 100644 index 00000000..30c116fe --- /dev/null +++ b/src/firetower/slack_app/views.py @@ -0,0 +1,24 @@ +from django.http import HttpResponse +from rest_framework.decorators import ( + api_view, + authentication_classes, + parser_classes, + permission_classes, +) +from rest_framework.parsers import FormParser, JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from slack_bolt.adapter.django import SlackRequestHandler + +from firetower.slack_app.authentication import SlackSigningSecretAuthentication +from firetower.slack_app.bolt import bolt_app + +handler = SlackRequestHandler(app=bolt_app) + + +@api_view(["POST"]) +@authentication_classes([SlackSigningSecretAuthentication]) +@permission_classes([IsAuthenticated]) +@parser_classes([FormParser, JSONParser]) +def slack_events(request: Request) -> HttpResponse: + return handler.handle(request._request) diff --git a/src/firetower/urls.py b/src/firetower/urls.py index b211cee7..84d76f19 100644 --- a/src/firetower/urls.py +++ b/src/firetower/urls.py @@ -24,6 +24,7 @@ path("admin/", admin.site.urls), path("api/", include("firetower.auth.urls")), path("api/", include("firetower.incidents.urls")), + path("slack/", include("firetower.slack_app.urls")), # Health check endpoints with Datadog metrics path("readyz/", health.readiness_check, name="readiness"), path("livez/", health.liveness_check, name="liveness"), From 2efed7ad833c837695252b1935ac7158e25ff70d Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Feb 2026 17:39:20 -0500 Subject: [PATCH 03/49] Add Datadog metrics instrumentation for slash commands --- .../slack_app/tests/test_handlers.py | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index 845a0f73..d878b380 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call, patch from firetower.slack_app.bolt import handle_inc @@ -10,7 +10,8 @@ def _make_body(self, text="", command="/inc"): def _make_command(self, command="/inc", text=""): return {"command": command, "text": text} - def test_help_returns_help_text(self): + @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") @@ -24,7 +25,8 @@ def test_help_returns_help_text(self): assert "Firetower Incident Bot" in response_text assert "/inc help" in response_text - def test_empty_text_returns_help(self): + @patch("firetower.slack_app.bolt.statsd") + def test_empty_text_returns_help(self, mock_statsd): ack = MagicMock() respond = MagicMock() body = self._make_body(text="") @@ -37,7 +39,8 @@ def test_empty_text_returns_help(self): response_text = respond.call_args[0][0] assert "Firetower Incident Bot" in response_text - def test_unknown_subcommand_returns_error(self): + @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") @@ -51,7 +54,8 @@ def test_unknown_subcommand_returns_error(self): assert "Unknown command" in response_text assert "/inc unknown" in response_text - def test_help_uses_testinc_command(self): + @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="/testinc") @@ -62,3 +66,38 @@ def test_help_uses_testinc_command(self): ack.assert_called_once() response_text = respond.call_args[0][0] assert "/testinc 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() + + try: + handle_inc(ack=ack, body=body, command=command, respond=respond) + except RuntimeError: + pass + + 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"] + ) From d2c55fa10a6930c0aee9b5ca92ccb1500d3db42f Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 11:00:40 -0500 Subject: [PATCH 04/49] Add signing_secret to CI config and fix ruff lint --- config.ci.toml | 1 + src/firetower/slack_app/authentication.py | 3 --- src/firetower/slack_app/block_kits.py | 0 src/firetower/slack_app/tests/test_views.py | 4 +++- 4 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 src/firetower/slack_app/block_kits.py diff --git a/config.ci.toml b/config.ci.toml index 2bedb8ac..bee6e492 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -22,6 +22,7 @@ bot_token = "test-bot-token" team_id = "test-bot-id" participant_sync_throttle_seconds = 300 app_token = "xapp-test-token" +signing_secret = "test-signing-secret" incident_feed_channel_id = "" always_invited_ids = [] incident_guide_message = "" diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py index d8ee2c0f..e1cb0288 100644 --- a/src/firetower/slack_app/authentication.py +++ b/src/firetower/slack_app/authentication.py @@ -60,8 +60,5 @@ def authenticate(self, request: Request) -> tuple | None: username=SERVICE_USERNAME, defaults={"is_active": True}, ) - if not user.has_usable_password(): - user.set_unusable_password() - user.save(update_fields=["password"]) return (user, None) diff --git a/src/firetower/slack_app/block_kits.py b/src/firetower/slack_app/block_kits.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py index 8e2d14f0..77adfaba 100644 --- a/src/firetower/slack_app/tests/test_views.py +++ b/src/firetower/slack_app/tests/test_views.py @@ -6,6 +6,7 @@ import pytest from django.conf import settings from django.http import HttpResponse +from django.test import Client from rest_framework.test import APIClient @@ -81,9 +82,10 @@ def test_valid_signature_returns_200(self, mock_handler): def test_csrf_not_enforced(self, mock_handler): mock_handler.handle.return_value = HttpResponse(status=200) + csrf_client = Client(enforce_csrf_checks=True) body = "command=/inc&text=help" ts, signature = self._sign_request(body) - response = self.client.post( + response = csrf_client.post( self.url, data=body, content_type="application/x-www-form-urlencoded", From b0db045244cb16660e054d85cdc2c3a54f958553 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 12:10:55 -0500 Subject: [PATCH 05/49] tweaks --- src/firetower/slack_app/authentication.py | 2 +- src/firetower/slack_app/tests/test_handlers.py | 6 +++--- src/firetower/slack_app/tests/test_views.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py index e1cb0288..5bc941d6 100644 --- a/src/firetower/slack_app/authentication.py +++ b/src/firetower/slack_app/authentication.py @@ -21,7 +21,7 @@ class SlackSigningSecretAuthentication(BaseAuthentication): configured signing secret. On success, returns a service user. """ - MAX_TIMESTAMP_AGE_SECONDS = 120 + MAX_TIMESTAMP_AGE_SECONDS = 300 def authenticate(self, request: Request) -> tuple | None: django_request = request._request diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index d878b380..3c46f47e 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, call, patch +import pytest + from firetower.slack_app.bolt import handle_inc @@ -90,10 +92,8 @@ def test_emits_failed_metric_on_error(self, mock_statsd): body = self._make_body(text="help") command = self._make_command() - try: + with pytest.raises(RuntimeError): handle_inc(ack=ack, body=body, command=command, respond=respond) - except RuntimeError: - pass mock_statsd.increment.assert_any_call( "slack_app.commands.submitted", tags=["subcommand:help"] diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py index 77adfaba..6eed3ed6 100644 --- a/src/firetower/slack_app/tests/test_views.py +++ b/src/firetower/slack_app/tests/test_views.py @@ -50,7 +50,7 @@ def test_invalid_signature_returns_403(self): assert response.status_code == 403 def test_expired_timestamp_returns_403(self): - old_ts = str(int(time.time()) - 300) + old_ts = str(int(time.time()) - 600) body = "command=/inc&text=help" _, signature = self._sign_request(body, old_ts) response = self.client.post( From de81cf354a4337ea3e728f4c5d91b178bea2641b Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 13:44:31 -0500 Subject: [PATCH 06/49] Rename slack app in help command --- src/firetower/slack_app/tests/test_handlers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index 3c46f47e..a7a930fa 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -24,7 +24,7 @@ def test_help_returns_help_text(self, mock_statsd): ack.assert_called_once() respond.assert_called_once() response_text = respond.call_args[0][0] - assert "Firetower Incident Bot" in response_text + assert "Firetower Slack App" in response_text assert "/inc help" in response_text @patch("firetower.slack_app.bolt.statsd") @@ -39,7 +39,7 @@ def test_empty_text_returns_help(self, mock_statsd): ack.assert_called_once() respond.assert_called_once() response_text = respond.call_args[0][0] - assert "Firetower Incident Bot" in response_text + assert "Firetower Slack App" in response_text @patch("firetower.slack_app.bolt.statsd") def test_unknown_subcommand_returns_error(self, mock_statsd): From 0ed965e10f394585067611d84c43242566781b63 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 12:38:57 -0500 Subject: [PATCH 07/49] Remove HTTP-based slack event handling in favor of Socket Mode --- src/firetower/slack_app/authentication.py | 64 -------------- src/firetower/slack_app/tests/test_views.py | 95 --------------------- src/firetower/slack_app/urls.py | 7 -- src/firetower/slack_app/views.py | 24 ------ src/firetower/urls.py | 1 - 5 files changed, 191 deletions(-) delete mode 100644 src/firetower/slack_app/authentication.py delete mode 100644 src/firetower/slack_app/tests/test_views.py delete mode 100644 src/firetower/slack_app/urls.py delete mode 100644 src/firetower/slack_app/views.py diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py deleted file mode 100644 index 5bc941d6..00000000 --- a/src/firetower/slack_app/authentication.py +++ /dev/null @@ -1,64 +0,0 @@ -import hashlib -import hmac -import time - -from django.conf import settings -from django.contrib.auth import get_user_model -from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed -from rest_framework.request import Request - -User = get_user_model() - -SERVICE_USERNAME = "firetower-slack-app" - - -class SlackSigningSecretAuthentication(BaseAuthentication): - """ - DRF authentication class that verifies Slack request signatures. - - Validates the X-Slack-Signature header using HMAC-SHA256 with the - configured signing secret. On success, returns a service user. - """ - - MAX_TIMESTAMP_AGE_SECONDS = 300 - - def authenticate(self, request: Request) -> tuple | None: - django_request = request._request - - timestamp = django_request.META.get("HTTP_X_SLACK_REQUEST_TIMESTAMP") - signature = django_request.META.get("HTTP_X_SLACK_SIGNATURE") - - if not timestamp or not signature: - return None - - try: - ts = int(timestamp) - except ValueError: - raise AuthenticationFailed("Invalid timestamp") - - if abs(time.time() - ts) > self.MAX_TIMESTAMP_AGE_SECONDS: - raise AuthenticationFailed("Request timestamp too old") - - signing_secret = settings.SLACK.get("SIGNING_SECRET", "") - raw_body = django_request.body - sig_basestring = f"v0:{timestamp}:{raw_body.decode('utf-8')}" - - computed = ( - "v0=" - + hmac.new( - signing_secret.encode("utf-8"), - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - - if not hmac.compare_digest(computed, signature): - raise AuthenticationFailed("Invalid signature") - - user, _ = User.objects.get_or_create( - username=SERVICE_USERNAME, - defaults={"is_active": True}, - ) - - return (user, None) diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py deleted file mode 100644 index 6eed3ed6..00000000 --- a/src/firetower/slack_app/tests/test_views.py +++ /dev/null @@ -1,95 +0,0 @@ -import hashlib -import hmac -import time -from unittest.mock import patch - -import pytest -from django.conf import settings -from django.http import HttpResponse -from django.test import Client -from rest_framework.test import APIClient - - -@pytest.mark.django_db -class TestSlackEventsEndpoint: - def setup_method(self): - self.client = APIClient() - self.url = "/slack/events" - self.signing_secret = settings.SLACK["SIGNING_SECRET"] - - def _sign_request(self, body: str, timestamp: str | None = None): - ts = timestamp or str(int(time.time())) - sig_basestring = f"v0:{ts}:{body}" - signature = ( - "v0=" - + hmac.new( - self.signing_secret.encode("utf-8"), - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - return ts, signature - - def test_missing_auth_headers_returns_403(self): - response = self.client.post( - self.url, - data="command=/inc&text=help", - content_type="application/x-www-form-urlencoded", - ) - assert response.status_code == 403 - - def test_invalid_signature_returns_403(self): - ts = str(int(time.time())) - response = self.client.post( - self.url, - data="command=/inc&text=help", - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, - HTTP_X_SLACK_SIGNATURE="v0=invalidsignature", - ) - assert response.status_code == 403 - - def test_expired_timestamp_returns_403(self): - old_ts = str(int(time.time()) - 600) - body = "command=/inc&text=help" - _, signature = self._sign_request(body, old_ts) - response = self.client.post( - self.url, - data=body, - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=old_ts, - HTTP_X_SLACK_SIGNATURE=signature, - ) - assert response.status_code == 403 - - @patch("firetower.slack_app.views.handler") - def test_valid_signature_returns_200(self, mock_handler): - mock_handler.handle.return_value = HttpResponse(status=200) - - body = "command=/inc&text=help" - ts, signature = self._sign_request(body) - response = self.client.post( - self.url, - data=body, - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, - HTTP_X_SLACK_SIGNATURE=signature, - ) - assert response.status_code == 200 - mock_handler.handle.assert_called_once() - - @patch("firetower.slack_app.views.handler") - def test_csrf_not_enforced(self, mock_handler): - mock_handler.handle.return_value = HttpResponse(status=200) - - csrf_client = Client(enforce_csrf_checks=True) - body = "command=/inc&text=help" - ts, signature = self._sign_request(body) - response = csrf_client.post( - self.url, - data=body, - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, - HTTP_X_SLACK_SIGNATURE=signature, - ) - assert response.status_code == 200 diff --git a/src/firetower/slack_app/urls.py b/src/firetower/slack_app/urls.py deleted file mode 100644 index d4b58a4f..00000000 --- a/src/firetower/slack_app/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from firetower.slack_app.views import slack_events - -urlpatterns = [ - path("events", slack_events), -] diff --git a/src/firetower/slack_app/views.py b/src/firetower/slack_app/views.py deleted file mode 100644 index 30c116fe..00000000 --- a/src/firetower/slack_app/views.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.http import HttpResponse -from rest_framework.decorators import ( - api_view, - authentication_classes, - parser_classes, - permission_classes, -) -from rest_framework.parsers import FormParser, JSONParser -from rest_framework.permissions import IsAuthenticated -from rest_framework.request import Request -from slack_bolt.adapter.django import SlackRequestHandler - -from firetower.slack_app.authentication import SlackSigningSecretAuthentication -from firetower.slack_app.bolt import bolt_app - -handler = SlackRequestHandler(app=bolt_app) - - -@api_view(["POST"]) -@authentication_classes([SlackSigningSecretAuthentication]) -@permission_classes([IsAuthenticated]) -@parser_classes([FormParser, JSONParser]) -def slack_events(request: Request) -> HttpResponse: - return handler.handle(request._request) diff --git a/src/firetower/urls.py b/src/firetower/urls.py index 84d76f19..b211cee7 100644 --- a/src/firetower/urls.py +++ b/src/firetower/urls.py @@ -24,7 +24,6 @@ path("admin/", admin.site.urls), path("api/", include("firetower.auth.urls")), path("api/", include("firetower.incidents.urls")), - path("slack/", include("firetower.slack_app.urls")), # Health check endpoints with Datadog metrics path("readyz/", health.readiness_check, name="readiness"), path("livez/", health.liveness_check, name="liveness"), From 354e635783b828687c2013dd4cf3a394f5365a20 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 12 Mar 2026 11:42:32 -0400 Subject: [PATCH 08/49] Mock Slack auth_test in tests instead of disabling token verification --- src/firetower/slack_app/tests/conftest.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/firetower/slack_app/tests/conftest.py diff --git a/src/firetower/slack_app/tests/conftest.py b/src/firetower/slack_app/tests/conftest.py new file mode 100644 index 00000000..b81ade36 --- /dev/null +++ b/src/firetower/slack_app/tests/conftest.py @@ -0,0 +1,15 @@ +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() From 91317283f45b6146db52afec1b11174766453d13 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Mar 2026 16:07:36 -0400 Subject: [PATCH 09/49] Change /inc to /ft (/testinc -> /ft-test) --- src/firetower/slack_app/tests/test_handlers.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index a7a930fa..975103cb 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -6,10 +6,10 @@ class TestHandleInc: - def _make_body(self, text="", command="/inc"): + def _make_body(self, text="", command="/ft"): return {"text": text, "command": command} - def _make_command(self, command="/inc", text=""): + def _make_command(self, command="/ft", text=""): return {"command": command, "text": text} @patch("firetower.slack_app.bolt.statsd") @@ -25,7 +25,7 @@ def test_help_returns_help_text(self, mock_statsd): respond.assert_called_once() response_text = respond.call_args[0][0] assert "Firetower Slack App" in response_text - assert "/inc help" in response_text + assert "/ft help" in response_text @patch("firetower.slack_app.bolt.statsd") def test_empty_text_returns_help(self, mock_statsd): @@ -54,20 +54,20 @@ def test_unknown_subcommand_returns_error(self, mock_statsd): respond.assert_called_once() response_text = respond.call_args[0][0] assert "Unknown command" in response_text - assert "/inc unknown" 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="/testinc") - command = self._make_command(command="/testinc") + 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 "/testinc help" in response_text + assert "/ft-test help" in response_text @patch("firetower.slack_app.bolt.statsd") def test_emits_submitted_and_completed_metrics(self, mock_statsd): From 252db4d48418703c95121e8d388b4cc7a6d27d41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Mar 2026 15:54:46 +0000 Subject: [PATCH 10/49] Scope Slack auth_test patch and normalize metric tags Applied via @cursor push command --- src/firetower/slack_app/tests/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/firetower/slack_app/tests/conftest.py b/src/firetower/slack_app/tests/conftest.py index b81ade36..9e762fff 100644 --- a/src/firetower/slack_app/tests/conftest.py +++ b/src/firetower/slack_app/tests/conftest.py @@ -13,3 +13,7 @@ }, ) mock_auth.start() + + +def pytest_sessionfinish(session, exitstatus): + mock_auth.stop() From cd94441054dcff51fdd6835b1d844a04e982f582 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 11:11:14 -0500 Subject: [PATCH 11/49] Add SlackService methods for channel management --- src/firetower/integrations/services/slack.py | 24 ++++++++++++++ src/firetower/integrations/test_slack.py | 33 ++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/firetower/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index d32ba120..3a82cd2e 100644 --- a/src/firetower/integrations/services/slack.py +++ b/src/firetower/integrations/services/slack.py @@ -277,6 +277,30 @@ def add_bookmark(self, channel_id: str, title: str, link: str) -> bool: ) 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://{self.team_id}.slack.com/archives/{channel_id}" diff --git a/src/firetower/integrations/test_slack.py b/src/firetower/integrations/test_slack.py index fba3f107..5791a494 100644 --- a/src/firetower/integrations/test_slack.py +++ b/src/firetower/integrations/test_slack.py @@ -370,6 +370,39 @@ def test_add_bookmark_api_error(self): 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") From 096ef3f02bec1d1724c54ba644fb053de50c8444 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 11:11:35 -0500 Subject: [PATCH 12/49] Add incident lifecycle hooks and wire into serializer --- src/firetower/incidents/tests/test_serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/firetower/incidents/tests/test_serializers.py b/src/firetower/incidents/tests/test_serializers.py index 1707e690..6d20c5f8 100644 --- a/src/firetower/incidents/tests/test_serializers.py +++ b/src/firetower/incidents/tests/test_serializers.py @@ -154,6 +154,7 @@ class TestIncidentWriteSerializerHooks: def enable_hooks(self, settings): settings.HOOKS_ENABLED = True + def setup_method(self): self.captain = User.objects.create_user( username="captain@example.com", From 6b9562af2edd826c6b7b17578d85051cbeedf4a0 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 11:12:30 -0500 Subject: [PATCH 13/49] Add /inc new command with modal for creating incidents --- .../slack_app/tests/test_handlers.py | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index 975103cb..3b20b403 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -1,10 +1,18 @@ 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} @@ -101,3 +109,172 @@ def test_emits_failed_metric_on_error(self, mock_statsd): 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 From 216b9cb0adde86baab5c8a82d2a6c6790a94d286 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 25 Mar 2026 10:59:37 -0400 Subject: [PATCH 14/49] Add get_incident_from_channel helper and on_title_changed hook --- src/firetower/slack_app/handlers/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/firetower/slack_app/handlers/utils.py diff --git a/src/firetower/slack_app/handlers/utils.py b/src/firetower/slack_app/handlers/utils.py new file mode 100644 index 00000000..4540b7c9 --- /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__contains=channel_id, + ).first() + if link: + return link.incident + return None From 534bcc6938195764c6110eb2768f4b398b67b261 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 25 Mar 2026 11:00:50 -0400 Subject: [PATCH 15/49] Add mitigated, resolved, reopen, severity, and subject command handlers --- src/firetower/slack_app/handlers/mitigated.py | 116 ++++++++++++ src/firetower/slack_app/handlers/reopen.py | 29 +++ src/firetower/slack_app/handlers/resolved.py | 169 ++++++++++++++++++ src/firetower/slack_app/handlers/severity.py | 38 ++++ src/firetower/slack_app/handlers/subject.py | 27 +++ 5 files changed, 379 insertions(+) create mode 100644 src/firetower/slack_app/handlers/mitigated.py create mode 100644 src/firetower/slack_app/handlers/reopen.py create mode 100644 src/firetower/slack_app/handlers/resolved.py create mode 100644 src/firetower/slack_app/handlers/severity.py create mode 100644 src/firetower/slack_app/handlers/subject.py diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py new file mode 100644 index 00000000..206a4326 --- /dev/null +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -0,0 +1,116 @@ +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 serializer.is_valid(): + serializer.save() + + 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 desc_serializer.is_valid(): + 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/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..4fbc9b43 --- /dev/null +++ b/src/firetower/slack_app/handlers/resolved.py @@ -0,0 +1,169 @@ +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 severity in ("P0", "P1", "P2"): + target_status = "Postmortem" + else: + target_status = "Done" + + data: dict[str, Any] = {"status": target_status, "severity": severity} + if captain_user: + data["captain"] = captain_user.email + + serializer = IncidentWriteSerializer(instance=incident, data=data, partial=True) + if serializer.is_valid(): + 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() if captain_user else 'Unknown'}" + ), + ) + + # 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}") From 8da039c371f754847ce044395fcbc84749c8bffe Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 25 Mar 2026 11:01:05 -0400 Subject: [PATCH 16/49] Wire new command handlers into bolt.py routing and update help text --- src/firetower/slack_app/bolt.py | 57 +++++++++++++++++++++++- src/firetower/slack_app/handlers/help.py | 5 +++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 73c422c0..0d500694 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -6,16 +6,41 @@ 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, handle_tag_options, ) +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", +} + _bolt_app: App | None = None @@ -31,9 +56,15 @@ def get_bolt_app() -> App: def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: - subcommand = (body.get("text") or "").strip().lower() + 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 ("", "help", "new") else "unknown" + (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) @@ -43,6 +74,26 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: 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") @@ -59,6 +110,8 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: def _register_views(app: App) -> None: """Register view handlers (modals, etc.) on the Bolt app.""" app.view("new_incident_modal")(handle_new_incident_submission) + app.view("mitigated_incident_modal")(handle_mitigated_submission) + app.view("resolved_incident_modal")(handle_resolved_submission) for action_id in ( "impact_type_tags", "affected_service_tags", diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index 669f04f9..5bfd15b5 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -9,5 +9,10 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: 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" ) From 39144086b09797ca65fc7ae5f7e2f9565239f58f Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Wed, 25 Mar 2026 11:01:27 -0400 Subject: [PATCH 17/49] Add tests for channel command handlers --- .../slack_app/tests/test_channel_commands.py | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 src/firetower/slack_app/tests/test_channel_commands.py 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"] + ) From 3ce2c2d41be5b64456a919a6ee5528a7cc14c3bf Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Wed, 25 Mar 2026 13:16:06 -0400 Subject: [PATCH 18/49] Address warden feedaback --- src/firetower/slack_app/handlers/mitigated.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py index 206a4326..877da6d6 100644 --- a/src/firetower/slack_app/handlers/mitigated.py +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -95,15 +95,24 @@ def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) - serializer = IncidentWriteSerializer( instance=incident, data={"status": "Mitigated"}, partial=True ) - if serializer.is_valid(): - serializer.save() + 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 desc_serializer.is_valid(): + if not desc_serializer.is_valid(): + logger.error("Mitigated description update failed: %s", desc_serializer.errors) + else: desc_serializer.save() client.chat_postMessage( From e1ceb7d47a7ba97ccbef626a492c16bef5c6db7d Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Wed, 25 Mar 2026 13:33:03 -0400 Subject: [PATCH 19/49] Handle validation errors and edge cases in command handlers --- src/firetower/slack_app/handlers/resolved.py | 29 ++++++++++++++++---- src/firetower/slack_app/handlers/utils.py | 2 +- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/firetower/slack_app/handlers/resolved.py b/src/firetower/slack_app/handlers/resolved.py index 4fbc9b43..e8aee1d2 100644 --- a/src/firetower/slack_app/handlers/resolved.py +++ b/src/firetower/slack_app/handlers/resolved.py @@ -144,25 +144,42 @@ def handle_resolved_submission(ack: Any, body: dict, view: dict, client: Any) -> 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} - if captain_user: - data["captain"] = captain_user.email + data: dict[str, Any] = { + "status": target_status, + "severity": severity, + "captain": captain_user.email, + } serializer = IncidentWriteSerializer(instance=incident, data=data, partial=True) - if serializer.is_valid(): - serializer.save() + 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() if captain_user else 'Unknown'}" + f"Severity: {severity} | Captain: {captain_user.get_full_name()}" ), ) diff --git a/src/firetower/slack_app/handlers/utils.py b/src/firetower/slack_app/handlers/utils.py index 4540b7c9..de3d576a 100644 --- a/src/firetower/slack_app/handlers/utils.py +++ b/src/firetower/slack_app/handlers/utils.py @@ -4,7 +4,7 @@ def get_incident_from_channel(channel_id: str) -> Incident | None: link = ExternalLink.objects.filter( type=ExternalLinkType.SLACK, - url__contains=channel_id, + url__endswith=channel_id, ).first() if link: return link.incident From 9aad8b419c4d34633ebd415b8679c646e130c46f Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 7 Apr 2026 16:50:15 -0400 Subject: [PATCH 20/49] Fix handle_inc imports to use renamed handle_command --- .../slack_app/tests/test_channel_commands.py | 24 +++++++++---------- .../slack_app/tests/test_handlers.py | 16 ++++++------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/firetower/slack_app/tests/test_channel_commands.py b/src/firetower/slack_app/tests/test_channel_commands.py index 3b10727c..28130e23 100644 --- a/src/firetower/slack_app/tests/test_channel_commands.py +++ b/src/firetower/slack_app/tests/test_channel_commands.py @@ -11,7 +11,7 @@ IncidentSeverity, IncidentStatus, ) -from firetower.slack_app.bolt import handle_inc +from firetower.slack_app.bolt import handle_command from firetower.slack_app.handlers.mitigated import ( handle_mitigated_command, handle_mitigated_submission, @@ -416,7 +416,7 @@ def test_mitigated_routes(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() @patch("firetower.slack_app.bolt.statsd") @@ -427,7 +427,7 @@ def test_mit_alias_routes(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() @patch("firetower.slack_app.bolt.statsd") @@ -438,7 +438,7 @@ def test_resolved_routes(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() @patch("firetower.slack_app.bolt.statsd") @@ -449,7 +449,7 @@ def test_fixed_alias_routes(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() @patch("firetower.slack_app.bolt.statsd") @@ -460,7 +460,7 @@ def test_reopen_routes(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() @patch("firetower.slack_app.bolt.statsd") @@ -471,7 +471,7 @@ def test_severity_routes_with_arg(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() assert mock_handler.call_args[1]["new_severity"] == "P0" @@ -482,7 +482,7 @@ def test_severity_no_arg_shows_usage(self, mock_statsd, incident): body = {"text": "severity", "channel_id": CHANNEL_ID} command = {"command": "/ft"} - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() assert "Usage" in respond.call_args[0][0] @@ -495,7 +495,7 @@ def test_sev_alias_routes(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() @patch("firetower.slack_app.bolt.statsd") @@ -506,7 +506,7 @@ def test_subject_routes_with_arg(self, mock_statsd, incident): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() assert mock_handler.call_args[1]["new_subject"] == "New Title Here" @@ -517,7 +517,7 @@ def test_subject_no_arg_shows_usage(self, mock_statsd, incident): body = {"text": "subject", "channel_id": CHANNEL_ID} command = {"command": "/ft"} - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() assert "Usage" in respond.call_args[0][0] @@ -530,7 +530,7 @@ def test_metrics_for_known_subcommands(self, mock_statsd, incident): command = {"command": "/ft"} with patch("firetower.slack_app.bolt.handle_reopen_command"): - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(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 index 3b20b403..4ffa0f06 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -5,7 +5,7 @@ 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.bolt import handle_command from firetower.slack_app.handlers.new_incident import ( handle_new_command, handle_new_incident_submission, @@ -27,7 +27,7 @@ def test_help_returns_help_text(self, mock_statsd): body = self._make_body(text="help") command = self._make_command() - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() respond.assert_called_once() @@ -42,7 +42,7 @@ def test_empty_text_returns_help(self, mock_statsd): body = self._make_body(text="") command = self._make_command() - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() respond.assert_called_once() @@ -56,7 +56,7 @@ def test_unknown_subcommand_returns_error(self, mock_statsd): body = self._make_body(text="unknown") command = self._make_command() - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() respond.assert_called_once() @@ -71,7 +71,7 @@ def test_help_uses_testinc_command(self, mock_statsd): 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) + handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() response_text = respond.call_args[0][0] @@ -84,7 +84,7 @@ def test_emits_submitted_and_completed_metrics(self, mock_statsd): body = self._make_body(text="help") command = self._make_command() - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_statsd.increment.assert_has_calls( [ @@ -101,7 +101,7 @@ def test_emits_failed_metric_on_error(self, mock_statsd): command = self._make_command() with pytest.raises(RuntimeError): - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) mock_statsd.increment.assert_any_call( "slack_app.commands.submitted", tags=["subcommand:help"] @@ -119,7 +119,7 @@ def test_new_subcommand_routes_correctly(self, mock_statsd, mock_bolt_app): body["trigger_id"] = "T12345" command = self._make_command() - handle_inc(ack=ack, body=body, command=command, respond=respond) + handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() mock_bolt_app.client.views_open.assert_called_once() From 27090edcb3f90fe39946ecf3f34cd29b081c2afb Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 7 Apr 2026 16:51:15 -0400 Subject: [PATCH 21/49] Use get_bolt_app() instead of nonexistent bolt_app in mitigated and resolved handlers --- src/firetower/slack_app/handlers/mitigated.py | 4 ++-- src/firetower/slack_app/handlers/resolved.py | 4 ++-- .../slack_app/tests/test_channel_commands.py | 16 ++++++++-------- src/firetower/slack_app/tests/test_handlers.py | 14 +++++++------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py index 877da6d6..08724578 100644 --- a/src/firetower/slack_app/handlers/mitigated.py +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -71,9 +71,9 @@ def handle_mitigated_command(ack: Any, body: dict, command: dict, respond: Any) respond("Could not open modal — missing trigger_id.") return - from firetower.slack_app.bolt import bolt_app # noqa: PLC0415 + from firetower.slack_app.bolt import get_bolt_app # noqa: PLC0415 - bolt_app.client.views_open( + get_bolt_app().client.views_open( trigger_id=trigger_id, view=_build_mitigated_modal(incident.incident_number, channel_id), ) diff --git a/src/firetower/slack_app/handlers/resolved.py b/src/firetower/slack_app/handlers/resolved.py index e8aee1d2..ca5fe8bc 100644 --- a/src/firetower/slack_app/handlers/resolved.py +++ b/src/firetower/slack_app/handlers/resolved.py @@ -102,9 +102,9 @@ def handle_resolved_command(ack: Any, body: dict, command: dict, respond: Any) - if slack_profile: captain_slack_id = slack_profile.external_id - from firetower.slack_app.bolt import bolt_app # noqa: PLC0415 + from firetower.slack_app.bolt import get_bolt_app # noqa: PLC0415 - bolt_app.client.views_open( + get_bolt_app().client.views_open( trigger_id=trigger_id, view=_build_resolved_modal( incident.incident_number, diff --git a/src/firetower/slack_app/tests/test_channel_commands.py b/src/firetower/slack_app/tests/test_channel_commands.py index 28130e23..520ac647 100644 --- a/src/firetower/slack_app/tests/test_channel_commands.py +++ b/src/firetower/slack_app/tests/test_channel_commands.py @@ -79,8 +79,8 @@ def test_returns_none_when_no_incidents(self, db): @pytest.mark.django_db class TestMitigatedCommand: - @patch("firetower.slack_app.bolt.bolt_app") - def test_opens_modal(self, mock_bolt_app, incident): + @patch("firetower.slack_app.bolt.get_bolt_app") + def test_opens_modal(self, mock_get_bolt_app, incident): ack = MagicMock() body = {"channel_id": CHANNEL_ID, "trigger_id": "T12345"} command = {"command": "/ft"} @@ -89,8 +89,8 @@ def test_opens_modal(self, mock_bolt_app, incident): 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"] + mock_get_bolt_app.return_value.client.views_open.assert_called_once() + view = mock_get_bolt_app.return_value.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"] @@ -161,8 +161,8 @@ def test_missing_incident_does_not_crash(self, db): @pytest.mark.django_db class TestResolvedCommand: - @patch("firetower.slack_app.bolt.bolt_app") - def test_opens_modal(self, mock_bolt_app, incident): + @patch("firetower.slack_app.bolt.get_bolt_app") + def test_opens_modal(self, mock_get_bolt_app, incident): ack = MagicMock() body = {"channel_id": CHANNEL_ID, "trigger_id": "T12345"} command = {"command": "/ft"} @@ -171,8 +171,8 @@ def test_opens_modal(self, mock_bolt_app, incident): 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"] + mock_get_bolt_app.return_value.client.views_open.assert_called_once() + view = mock_get_bolt_app.return_value.client.views_open.call_args[1]["view"] assert view["callback_id"] == "resolved_incident_modal" def test_no_incident_responds_error(self, db): diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index 4ffa0f06..aceb9232 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -110,9 +110,9 @@ def test_emits_failed_metric_on_error(self, mock_statsd): "slack_app.commands.failed", tags=["subcommand:help"] ) - @patch("firetower.slack_app.bolt.bolt_app") + @patch("firetower.slack_app.bolt.get_bolt_app") @patch("firetower.slack_app.bolt.statsd") - def test_new_subcommand_routes_correctly(self, mock_statsd, mock_bolt_app): + def test_new_subcommand_routes_correctly(self, mock_statsd, mock_get_bolt_app): ack = MagicMock() respond = MagicMock() body = self._make_body(text="new") @@ -122,13 +122,13 @@ def test_new_subcommand_routes_correctly(self, mock_statsd, mock_bolt_app): handle_command(ack=ack, body=body, command=command, respond=respond) ack.assert_called_once() - mock_bolt_app.client.views_open.assert_called_once() + mock_get_bolt_app.return_value.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): + @patch("firetower.slack_app.bolt.get_bolt_app") + def test_new_opens_modal(self, mock_get_bolt_app): ack = MagicMock() body = {"trigger_id": "T12345"} command = {"text": "new"} @@ -137,8 +137,8 @@ def test_new_opens_modal(self, mock_bolt_app): 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"] + mock_get_bolt_app.return_value.client.views_open.assert_called_once() + view = mock_get_bolt_app.return_value.client.views_open.call_args[1]["view"] assert view["callback_id"] == "new_incident_modal" assert view["type"] == "modal" From 296f264b3c16131dbae81fce1e269c9a9e81883d Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 7 Apr 2026 16:51:36 -0400 Subject: [PATCH 22/49] Remove unused get_channel_history from SlackService --- src/firetower/integrations/services/slack.py | 24 -------------- src/firetower/integrations/test_slack.py | 33 -------------------- 2 files changed, 57 deletions(-) diff --git a/src/firetower/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index 3a82cd2e..d32ba120 100644 --- a/src/firetower/integrations/services/slack.py +++ b/src/firetower/integrations/services/slack.py @@ -277,30 +277,6 @@ def add_bookmark(self, channel_id: str, title: str, link: str) -> bool: ) 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://{self.team_id}.slack.com/archives/{channel_id}" diff --git a/src/firetower/integrations/test_slack.py b/src/firetower/integrations/test_slack.py index 5791a494..fba3f107 100644 --- a/src/firetower/integrations/test_slack.py +++ b/src/firetower/integrations/test_slack.py @@ -370,39 +370,6 @@ def test_add_bookmark_api_error(self): 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") From 4bdf8670821490bcc340b7b073f92042f415f6aa Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 7 Apr 2026 16:52:07 -0400 Subject: [PATCH 23/49] Use IncidentStatus enum instead of raw strings in handlers --- src/firetower/slack_app/handlers/mitigated.py | 3 ++- src/firetower/slack_app/handlers/reopen.py | 5 +++-- src/firetower/slack_app/handlers/resolved.py | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py index 08724578..01bc5984 100644 --- a/src/firetower/slack_app/handlers/mitigated.py +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -1,6 +1,7 @@ import logging from typing import Any +from firetower.incidents.models import IncidentStatus from firetower.incidents.serializers import IncidentWriteSerializer from firetower.slack_app.handlers.utils import get_incident_from_channel @@ -93,7 +94,7 @@ def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) - return serializer = IncidentWriteSerializer( - instance=incident, data={"status": "Mitigated"}, partial=True + instance=incident, data={"status": IncidentStatus.MITIGATED}, partial=True ) if not serializer.is_valid(): logger.error("Mitigated status update failed: %s", serializer.errors) diff --git a/src/firetower/slack_app/handlers/reopen.py b/src/firetower/slack_app/handlers/reopen.py index c1f2e32e..c056f304 100644 --- a/src/firetower/slack_app/handlers/reopen.py +++ b/src/firetower/slack_app/handlers/reopen.py @@ -1,6 +1,7 @@ import logging from typing import Any +from firetower.incidents.models import IncidentStatus from firetower.incidents.serializers import IncidentWriteSerializer from firetower.slack_app.handlers.utils import get_incident_from_channel @@ -15,12 +16,12 @@ def handle_reopen_command(ack: Any, body: dict, command: dict, respond: Any) -> respond("Could not find an incident associated with this channel.") return - if incident.status == "Active": + if incident.status == IncidentStatus.ACTIVE: respond(f"{incident.incident_number} is already Active.") return serializer = IncidentWriteSerializer( - instance=incident, data={"status": "Active"}, partial=True + instance=incident, data={"status": IncidentStatus.ACTIVE}, partial=True ) if serializer.is_valid(): serializer.save() diff --git a/src/firetower/slack_app/handlers/resolved.py b/src/firetower/slack_app/handlers/resolved.py index ca5fe8bc..fbd1e671 100644 --- a/src/firetower/slack_app/handlers/resolved.py +++ b/src/firetower/slack_app/handlers/resolved.py @@ -3,7 +3,7 @@ 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.models import IncidentSeverity, IncidentStatus from firetower.incidents.serializers import IncidentWriteSerializer from firetower.slack_app.handlers.utils import get_incident_from_channel @@ -155,9 +155,9 @@ def handle_resolved_submission(ack: Any, body: dict, view: dict, client: Any) -> return if severity in ("P0", "P1", "P2"): - target_status = "Postmortem" + target_status = IncidentStatus.POSTMORTEM else: - target_status = "Done" + target_status = IncidentStatus.DONE data: dict[str, Any] = { "status": target_status, From b09c6ba1e1bf6cece57f315043ffdb5a9a8eb473 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 7 Apr 2026 16:52:17 -0400 Subject: [PATCH 24/49] Use more precise URL filter in get_incident_from_channel --- src/firetower/slack_app/handlers/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firetower/slack_app/handlers/utils.py b/src/firetower/slack_app/handlers/utils.py index de3d576a..7cb22aaf 100644 --- a/src/firetower/slack_app/handlers/utils.py +++ b/src/firetower/slack_app/handlers/utils.py @@ -4,7 +4,7 @@ def get_incident_from_channel(channel_id: str) -> Incident | None: link = ExternalLink.objects.filter( type=ExternalLinkType.SLACK, - url__endswith=channel_id, + url__endswith=f"/archives/{channel_id}", ).first() if link: return link.incident From 2be7e4caa66f14bf391889400e5d04871c57c4a3 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 7 Apr 2026 16:52:27 -0400 Subject: [PATCH 25/49] Move module-level mock_auth to a proper pytest fixture --- src/firetower/slack_app/tests/conftest.py | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/firetower/slack_app/tests/conftest.py b/src/firetower/slack_app/tests/conftest.py index 9e762fff..a0e8c002 100644 --- a/src/firetower/slack_app/tests/conftest.py +++ b/src/firetower/slack_app/tests/conftest.py @@ -1,19 +1,19 @@ from unittest.mock import patch +import pytest 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() +@pytest.fixture(autouse=True) +def mock_slack_auth(): + with patch.object( + WebClient, + "auth_test", + return_value={ + "ok": True, + "user_id": "U0000", + "team_id": "T0000", + "bot_id": "B0000", + }, + ): + yield From efe5f7c2e1b4f879c97a1c42743be66a561171d3 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Wed, 8 Apr 2026 13:19:07 -0400 Subject: [PATCH 26/49] Add /ft update command with modal for editing incident metadata --- src/firetower/slack_app/bolt.py | 9 + src/firetower/slack_app/handlers/help.py | 1 + .../slack_app/handlers/update_incident.py | 305 ++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 src/firetower/slack_app/handlers/update_incident.py diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 0d500694..c3261430 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -22,6 +22,10 @@ ) 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.update_incident import ( + handle_update_command, + handle_update_incident_submission, +) logger = logging.getLogger(__name__) @@ -39,6 +43,8 @@ "sev", "setseverity", "subject", + "update", + "edit", } _bolt_app: App | None = None @@ -87,6 +93,8 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: respond(f"Usage: `{cmd} severity <P0-P4>`") else: handle_severity_command(ack, body, command, respond, new_severity=args) + elif subcommand in ("update", "edit"): + handle_update_command(ack, body, command, respond) elif subcommand == "subject": if not args: ack() @@ -110,6 +118,7 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: def _register_views(app: App) -> None: """Register view handlers (modals, etc.) on the Bolt app.""" app.view("new_incident_modal")(handle_new_incident_submission) + app.view("update_incident_modal")(handle_update_incident_submission) app.view("mitigated_incident_modal")(handle_mitigated_submission) app.view("resolved_incident_modal")(handle_resolved_submission) for action_id in ( diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index 5bfd15b5..be4d5d05 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -9,6 +9,7 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: f"Usage: `{cmd} <command>`\n\n" f"Available commands:\n" f" `{cmd} new` - Create a new incident\n" + f" `{cmd} update` - Update incident metadata\n" f" `{cmd} mitigated` - Mark incident as mitigated\n" f" `{cmd} resolved` - Mark incident as resolved\n" f" `{cmd} reopen` - Reopen an incident\n" diff --git a/src/firetower/slack_app/handlers/update_incident.py b/src/firetower/slack_app/handlers/update_incident.py new file mode 100644 index 00000000..3cab7c31 --- /dev/null +++ b/src/firetower/slack_app/handlers/update_incident.py @@ -0,0 +1,305 @@ +import logging +from typing import Any + +from firetower.incidents.models import Incident, IncidentSeverity +from firetower.incidents.serializers import IncidentWriteSerializer +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + + +def _build_update_incident_modal(incident: Incident, channel_id: str) -> dict: + severity_options = [ + { + "text": {"type": "plain_text", "text": sev.label}, + "value": sev.value, + } + for sev in IncidentSeverity + ] + current_severity_option = { + "text": {"type": "plain_text", "text": incident.severity.label}, + "value": incident.severity.value, + } + + impact_type_initial = [ + {"text": {"type": "plain_text", "text": name}, "value": name} + for name in incident.impact_type_tag_names + ] + affected_service_initial = [ + {"text": {"type": "plain_text", "text": name}, "value": name} + for name in incident.affected_service_tag_names + ] + affected_region_initial = [ + {"text": {"type": "plain_text", "text": name}, "value": name} + for name in incident.affected_region_tag_names + ] + + severity_element: dict[str, Any] = { + "type": "static_select", + "action_id": "severity", + "placeholder": {"type": "plain_text", "text": "Select severity"}, + "options": severity_options, + "initial_option": current_severity_option, + } + + title_element: dict[str, Any] = { + "type": "plain_text_input", + "action_id": "title", + "placeholder": {"type": "plain_text", "text": "Brief incident title"}, + "initial_value": incident.title or "", + } + + description_element: dict[str, Any] = { + "type": "plain_text_input", + "action_id": "description", + "multiline": True, + "placeholder": {"type": "plain_text", "text": "What's happening?"}, + } + if incident.description: + description_element["initial_value"] = incident.description + + impact_summary_element: dict[str, Any] = { + "type": "plain_text_input", + "action_id": "impact_summary", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "What is the user/business impact?", + }, + } + if incident.impact_summary: + impact_summary_element["initial_value"] = incident.impact_summary + + impact_type_element: dict[str, Any] = { + "type": "multi_external_select", + "action_id": "impact_type_tags", + "min_query_length": 0, + "placeholder": {"type": "plain_text", "text": "Select impact types"}, + } + if impact_type_initial: + impact_type_element["initial_options"] = impact_type_initial + + affected_service_element: dict[str, Any] = { + "type": "multi_external_select", + "action_id": "affected_service_tags", + "min_query_length": 0, + "placeholder": {"type": "plain_text", "text": "Select affected services"}, + } + if affected_service_initial: + affected_service_element["initial_options"] = affected_service_initial + + affected_region_element: dict[str, Any] = { + "type": "multi_external_select", + "action_id": "affected_region_tags", + "min_query_length": 0, + "placeholder": {"type": "plain_text", "text": "Select affected regions"}, + } + if affected_region_initial: + affected_region_element["initial_options"] = affected_region_initial + + private_element: dict[str, Any] = { + "type": "checkboxes", + "action_id": "is_private", + "options": [ + { + "text": {"type": "plain_text", "text": "Private incident"}, + "value": "private", + } + ], + } + if incident.is_private: + private_element["initial_options"] = [ + { + "text": {"type": "plain_text", "text": "Private incident"}, + "value": "private", + } + ] + + blocks = [ + { + "type": "input", + "block_id": "severity_block", + "element": severity_element, + "label": {"type": "plain_text", "text": "Severity"}, + }, + { + "type": "input", + "block_id": "title_block", + "element": title_element, + "label": {"type": "plain_text", "text": "Title"}, + }, + { + "type": "input", + "block_id": "description_block", + "optional": True, + "element": description_element, + "label": {"type": "plain_text", "text": "Description"}, + }, + { + "type": "input", + "block_id": "impact_summary_block", + "optional": True, + "element": impact_summary_element, + "label": {"type": "plain_text", "text": "Impact Summary"}, + }, + { + "type": "input", + "block_id": "impact_type_block", + "optional": True, + "element": impact_type_element, + "label": {"type": "plain_text", "text": "Impact Type"}, + }, + { + "type": "input", + "block_id": "affected_service_block", + "optional": True, + "element": affected_service_element, + "label": {"type": "plain_text", "text": "Affected Service"}, + }, + { + "type": "input", + "block_id": "affected_region_block", + "optional": True, + "element": affected_region_element, + "label": {"type": "plain_text", "text": "Affected Region"}, + }, + { + "type": "input", + "block_id": "private_block", + "optional": True, + "element": private_element, + "label": {"type": "plain_text", "text": "Visibility"}, + }, + ] + + return { + "type": "modal", + "callback_id": "update_incident_modal", + "title": {"type": "plain_text", "text": incident.incident_number}, + "submit": {"type": "plain_text", "text": "Update"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "private_metadata": channel_id, + "blocks": blocks, + } + + +def handle_update_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 get_bolt_app # noqa: PLC0415 + + get_bolt_app().client.views_open( + trigger_id=trigger_id, + view=_build_update_incident_modal(incident, channel_id), + ) + + +def handle_update_incident_submission( + ack: Any, body: dict, view: dict, client: Any +) -> None: + values = view.get("state", {}).get("values", {}) + channel_id = view.get("private_metadata", "") + + title = values.get("title_block", {}).get("title", {}).get("value", "").strip() + severity = ( + values.get("severity_block", {}) + .get("severity", {}) + .get("selected_option", {}) + .get("value") + ) + description = ( + values.get("description_block", {}).get("description", {}).get("value") or "" + ) + impact_summary = ( + values.get("impact_summary_block", {}).get("impact_summary", {}).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) + + if not title: + ack( + response_action="errors", + errors={"title_block": "This field is required."}, + ) + return + + ack() + + incident = get_incident_from_channel(channel_id) + if not incident: + logger.error("Update submission: no incident for channel %s", channel_id) + return + + data: dict[str, Any] = { + "title": title, + "severity": severity, + "description": description, + "impact_summary": impact_summary, + "is_private": is_private, + "impact_type_tags": impact_type_tags, + "affected_service_tags": affected_service_tags, + "affected_region_tags": affected_region_tags, + } + + serializer = IncidentWriteSerializer(instance=incident, data=data, partial=True) + if not serializer.is_valid(): + logger.error("Incident update validation failed: %s", serializer.errors) + client.chat_postMessage( + channel=channel_id, + text=f"Failed to update incident: {serializer.errors}", + ) + return + + try: + serializer.save() + except Exception: + logger.exception("Failed to update incident from Slack modal") + client.chat_postMessage( + channel=channel_id, + text="Something went wrong updating the incident. Please try again.", + ) + return + + client.chat_postMessage( + channel=channel_id, + text=f"*{incident.incident_number}* has been updated.", + ) From 9e4aefb1568c623cdc6fba0f159f22a1e5285645 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Wed, 8 Apr 2026 13:19:17 -0400 Subject: [PATCH 27/49] Add tests for /ft update command handler --- .../tests/handlers/test_update_incident.py | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/firetower/slack_app/tests/handlers/test_update_incident.py diff --git a/src/firetower/slack_app/tests/handlers/test_update_incident.py b/src/firetower/slack_app/tests/handlers/test_update_incident.py new file mode 100644 index 00000000..a3f190bd --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_update_incident.py @@ -0,0 +1,270 @@ +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, + Tag, + TagType, +) +from firetower.slack_app.handlers.update_incident import ( + _build_update_incident_modal, + handle_update_command, + handle_update_incident_submission, +) + +CHANNEL_ID = "C_TEST_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, + description="Original description", + impact_summary="Original impact", + captain=user, + reporter=user, + ) + inc.save() + ExternalLink.objects.create( + incident=inc, + type=ExternalLinkType.SLACK, + url=f"https://slack.com/archives/{CHANNEL_ID}", + ) + return inc + + +@pytest.mark.django_db +class TestUpdateCommand: + @patch("firetower.slack_app.bolt.get_bolt_app") + def test_opens_modal(self, mock_get_bolt_app, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID, "trigger_id": "T12345"} + command = {"command": "/ft"} + respond = MagicMock() + + handle_update_command(ack, body, command, respond) + + ack.assert_called_once() + mock_get_bolt_app.return_value.client.views_open.assert_called_once() + view = mock_get_bolt_app.return_value.client.views_open.call_args[1]["view"] + assert view["callback_id"] == "update_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_update_command(ack, body, command, respond) + + ack.assert_called_once() + respond.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + def test_missing_trigger_id(self, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_update_command(ack, body, command, respond) + + ack.assert_called_once() + respond.assert_called_once() + assert "trigger_id" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestUpdateIncidentModal: + def test_prefills_title(self, incident): + modal = _build_update_incident_modal(incident, CHANNEL_ID) + title_block = next(b for b in modal["blocks"] if b["block_id"] == "title_block") + assert title_block["element"]["initial_value"] == "Test Incident" + + def test_prefills_severity(self, incident): + modal = _build_update_incident_modal(incident, CHANNEL_ID) + sev_block = next( + b for b in modal["blocks"] if b["block_id"] == "severity_block" + ) + assert sev_block["element"]["initial_option"]["value"] == "P2" + + def test_prefills_description(self, incident): + modal = _build_update_incident_modal(incident, CHANNEL_ID) + desc_block = next( + b for b in modal["blocks"] if b["block_id"] == "description_block" + ) + assert desc_block["element"]["initial_value"] == "Original description" + + def test_prefills_impact_summary(self, incident): + modal = _build_update_incident_modal(incident, CHANNEL_ID) + block = next( + b for b in modal["blocks"] if b["block_id"] == "impact_summary_block" + ) + assert block["element"]["initial_value"] == "Original impact" + + def test_prefills_private_checkbox(self, incident): + incident.is_private = True + incident.save() + modal = _build_update_incident_modal(incident, CHANNEL_ID) + block = next(b for b in modal["blocks"] if b["block_id"] == "private_block") + assert "initial_options" in block["element"] + assert block["element"]["initial_options"][0]["value"] == "private" + + def test_private_unchecked_by_default(self, incident): + modal = _build_update_incident_modal(incident, CHANNEL_ID) + block = next(b for b in modal["blocks"] if b["block_id"] == "private_block") + assert "initial_options" not in block["element"] + + def test_prefills_tags(self, incident): + tag = Tag.objects.create(name="api-server", type=TagType.AFFECTED_SERVICE) + incident.affected_service_tags.add(tag) + + modal = _build_update_incident_modal(incident, CHANNEL_ID) + block = next( + b for b in modal["blocks"] if b["block_id"] == "affected_service_block" + ) + assert "initial_options" in block["element"] + assert block["element"]["initial_options"][0]["value"] == "api-server" + + def test_empty_tags_no_initial_options(self, incident): + modal = _build_update_incident_modal(incident, CHANNEL_ID) + block = next( + b for b in modal["blocks"] if b["block_id"] == "affected_service_block" + ) + assert "initial_options" not in block["element"] + + +@pytest.mark.django_db +class TestUpdateIncidentSubmission: + @patch("firetower.incidents.serializers.on_title_changed") + def test_updates_incident(self, mock_title_hook, incident): + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_CAPTAIN"}} + view = { + "private_metadata": CHANNEL_ID, + "state": { + "values": { + "title_block": {"title": {"value": "Updated Title"}}, + "severity_block": { + "severity": {"selected_option": {"value": "P1"}} + }, + "description_block": { + "description": {"value": "Updated description"} + }, + "impact_summary_block": { + "impact_summary": {"value": "Updated impact"} + }, + "impact_type_block": {"impact_type_tags": {"selected_options": []}}, + "affected_service_block": { + "affected_service_tags": {"selected_options": []} + }, + "affected_region_block": { + "affected_region_tags": {"selected_options": []} + }, + "private_block": {"is_private": {"selected_options": []}}, + } + }, + } + + handle_update_incident_submission(ack, body, view, client) + + ack.assert_called_once_with() + incident.refresh_from_db() + assert incident.title == "Updated Title" + assert incident.severity == IncidentSeverity.P1 + assert incident.description == "Updated description" + assert incident.impact_summary == "Updated impact" + client.chat_postMessage.assert_called_once() + assert "updated" in client.chat_postMessage.call_args[1]["text"] + + def test_empty_title_returns_modal_error(self, incident): + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_CAPTAIN"}} + view = { + "private_metadata": CHANNEL_ID, + "state": { + "values": { + "title_block": {"title": {"value": ""}}, + "severity_block": { + "severity": {"selected_option": {"value": "P1"}} + }, + "description_block": {"description": {"value": ""}}, + "impact_summary_block": {"impact_summary": {"value": ""}}, + "impact_type_block": {"impact_type_tags": {"selected_options": []}}, + "affected_service_block": { + "affected_service_tags": {"selected_options": []} + }, + "affected_region_block": { + "affected_region_tags": {"selected_options": []} + }, + "private_block": {"is_private": {"selected_options": []}}, + } + }, + } + + handle_update_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() + + 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": { + "title_block": {"title": {"value": "Some Title"}}, + "severity_block": { + "severity": {"selected_option": {"value": "P2"}} + }, + "description_block": {"description": {"value": ""}}, + "impact_summary_block": {"impact_summary": {"value": ""}}, + "impact_type_block": {"impact_type_tags": {"selected_options": []}}, + "affected_service_block": { + "affected_service_tags": {"selected_options": []} + }, + "affected_region_block": { + "affected_region_tags": {"selected_options": []} + }, + "private_block": {"is_private": {"selected_options": []}}, + } + }, + } + + handle_update_incident_submission(ack, body, view, client) + + ack.assert_called_once() + client.chat_postMessage.assert_not_called() From d8bad00b6573843e1f4ab8413197a5adbf1b1329 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 15:01:44 -0400 Subject: [PATCH 28/49] Add alias notes to help text --- src/firetower/slack_app/handlers/help.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index be4d5d05..4c444d82 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -13,7 +13,11 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: 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 <P0-P4>` - Change incident severity\n" + f" `{cmd} severity <P0-P4>` - Change incident severity (alias: `sev`)\n" f" `{cmd} subject <title>` - Change incident title\n" + f" `{cmd} statuspage` - Statuspage (not yet implemented)\n" + f" `{cmd} dumpslack` - Dump slack history (not yet implemented)\n" f" `{cmd} help` - Show this help message\n" + f"\n" + f"Aliases: `mit` = `mitigated`, `fixed` = `resolved`, `sev` = `severity`\n" ) From f70653d67f1be4c134e2ab849cca3d870e02abba Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 15:02:00 -0400 Subject: [PATCH 29/49] Add stub handlers for statuspage and dumpslack commands --- src/firetower/slack_app/bolt.py | 8 +++ src/firetower/slack_app/handlers/dumpslack.py | 9 ++++ .../slack_app/handlers/statuspage.py | 9 ++++ .../slack_app/tests/test_channel_commands.py | 54 +++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 src/firetower/slack_app/handlers/dumpslack.py create mode 100644 src/firetower/slack_app/handlers/statuspage.py diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index c3261430..7283b475 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -5,6 +5,7 @@ from django.conf import settings from slack_bolt import App +from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command from firetower.slack_app.handlers.help import handle_help_command from firetower.slack_app.handlers.mitigated import ( handle_mitigated_command, @@ -21,6 +22,7 @@ handle_resolved_submission, ) from firetower.slack_app.handlers.severity import handle_severity_command +from firetower.slack_app.handlers.statuspage import handle_statuspage_command from firetower.slack_app.handlers.subject import handle_subject_command from firetower.slack_app.handlers.update_incident import ( handle_update_command, @@ -45,6 +47,8 @@ "subject", "update", "edit", + "statuspage", + "dumpslack", } _bolt_app: App | None = None @@ -102,6 +106,10 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: respond(f"Usage: `{cmd} subject <new title>`") else: handle_subject_command(ack, body, command, respond, new_subject=args) + elif subcommand == "statuspage": + handle_statuspage_command(ack, command, respond) + elif subcommand == "dumpslack": + handle_dumpslack_command(ack, command, respond) else: ack() cmd = command.get("command", "/ft") diff --git a/src/firetower/slack_app/handlers/dumpslack.py b/src/firetower/slack_app/handlers/dumpslack.py new file mode 100644 index 00000000..79cdba1a --- /dev/null +++ b/src/firetower/slack_app/handlers/dumpslack.py @@ -0,0 +1,9 @@ +from typing import Any + + +def handle_dumpslack_command(ack: Any, command: dict, respond: Any) -> None: + ack() + respond( + "Dumpslack is not yet implemented in Firetower." + " Use `/inc dumpslack` in the meantime." + ) diff --git a/src/firetower/slack_app/handlers/statuspage.py b/src/firetower/slack_app/handlers/statuspage.py new file mode 100644 index 00000000..af48efaa --- /dev/null +++ b/src/firetower/slack_app/handlers/statuspage.py @@ -0,0 +1,9 @@ +from typing import Any + + +def handle_statuspage_command(ack: Any, command: dict, respond: Any) -> None: + ack() + respond( + "Statuspage is not yet implemented in Firetower." + " Use `/inc statuspage` in the meantime." + ) diff --git a/src/firetower/slack_app/tests/test_channel_commands.py b/src/firetower/slack_app/tests/test_channel_commands.py index 520ac647..e60b3d1e 100644 --- a/src/firetower/slack_app/tests/test_channel_commands.py +++ b/src/firetower/slack_app/tests/test_channel_commands.py @@ -12,6 +12,7 @@ IncidentStatus, ) from firetower.slack_app.bolt import handle_command +from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command from firetower.slack_app.handlers.mitigated import ( handle_mitigated_command, handle_mitigated_submission, @@ -22,6 +23,7 @@ handle_resolved_submission, ) from firetower.slack_app.handlers.severity import handle_severity_command +from firetower.slack_app.handlers.statuspage import handle_statuspage_command from firetower.slack_app.handlers.subject import handle_subject_command from firetower.slack_app.handlers.utils import get_incident_from_channel @@ -535,3 +537,55 @@ def test_metrics_for_known_subcommands(self, mock_statsd, incident): mock_statsd.increment.assert_any_call( "slack_app.commands.submitted", tags=["subcommand:reopen"] ) + + @patch("firetower.slack_app.bolt.statsd") + def test_statuspage_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "statuspage", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch( + "firetower.slack_app.bolt.handle_statuspage_command" + ) as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_dumpslack_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "dumpslack", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_dumpslack_command") as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + +class TestStatuspageCommand: + def test_returns_not_implemented(self): + ack = MagicMock() + respond = MagicMock() + command = {"command": "/ft"} + + handle_statuspage_command(ack, command, respond) + + ack.assert_called_once() + respond.assert_called_once() + assert "not yet implemented" in respond.call_args[0][0] + assert "/inc statuspage" in respond.call_args[0][0] + + +class TestDumpslackCommand: + def test_returns_not_implemented(self): + ack = MagicMock() + respond = MagicMock() + command = {"command": "/ft"} + + handle_dumpslack_command(ack, command, respond) + + ack.assert_called_once() + respond.assert_called_once() + assert "not yet implemented" in respond.call_args[0][0] + assert "/inc dumpslack" in respond.call_args[0][0] From d6cbfb7c86af71ea85708c2b0571c98538435c89 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 15:40:40 -0400 Subject: [PATCH 30/49] Fix severity fallback, document all aliases in help, remove signing_secret, use plain text for mitigation notes --- config.ci.toml | 1 - config.example.toml | 1 - src/firetower/config.py | 2 -- src/firetower/settings.py | 2 -- src/firetower/slack_app/handlers/help.py | 2 +- src/firetower/slack_app/handlers/mitigated.py | 2 +- src/firetower/slack_app/handlers/resolved.py | 4 +++- 7 files changed, 5 insertions(+), 9 deletions(-) diff --git a/config.ci.toml b/config.ci.toml index bee6e492..2bedb8ac 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -22,7 +22,6 @@ bot_token = "test-bot-token" team_id = "test-bot-id" participant_sync_throttle_seconds = 300 app_token = "xapp-test-token" -signing_secret = "test-signing-secret" incident_feed_channel_id = "" always_invited_ids = [] incident_guide_message = "" diff --git a/config.example.toml b/config.example.toml index 4d4d74f7..c981fa0a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -21,7 +21,6 @@ bot_token = "" team_id = "<slack-team-id>" participant_sync_throttle_seconds = 300 app_token = "" -signing_secret = "" incident_feed_channel_id = "" always_invited_ids = [] incident_guide_message = "This is the message posted whenever a new incident slack channel is created." diff --git a/src/firetower/config.py b/src/firetower/config.py index ce56c29e..7f8234f7 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -40,7 +40,6 @@ class SlackConfig: team_id: str participant_sync_throttle_seconds: int app_token: str - signing_secret: str incident_feed_channel_id: str = "" always_invited_ids: list[str] = field(default_factory=list) incident_guide_message: str = "" @@ -125,7 +124,6 @@ def __init__(self) -> None: team_id="", participant_sync_throttle_seconds=0, app_token="", - signing_secret="", incident_feed_channel_id="", always_invited_ids=[], incident_guide_message="", diff --git a/src/firetower/settings.py b/src/firetower/settings.py index f28b9ffa..b224befc 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -228,7 +228,6 @@ class SlackSettings(TypedDict): BOT_TOKEN: str TEAM_ID: str APP_TOKEN: str - SIGNING_SECRET: str INCIDENT_FEED_CHANNEL_ID: str ALWAYS_INVITED_IDS: list[str] INCIDENT_GUIDE_MESSAGE: str @@ -238,7 +237,6 @@ class SlackSettings(TypedDict): "BOT_TOKEN": config.slack.bot_token, "TEAM_ID": config.slack.team_id, "APP_TOKEN": config.slack.app_token, - "SIGNING_SECRET": config.slack.signing_secret, "INCIDENT_FEED_CHANNEL_ID": config.slack.incident_feed_channel_id, "ALWAYS_INVITED_IDS": config.slack.always_invited_ids, "INCIDENT_GUIDE_MESSAGE": config.slack.incident_guide_message, diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index 4c444d82..45695b42 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -19,5 +19,5 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: f" `{cmd} dumpslack` - Dump slack history (not yet implemented)\n" f" `{cmd} help` - Show this help message\n" f"\n" - f"Aliases: `mit` = `mitigated`, `fixed` = `resolved`, `sev` = `severity`\n" + f"Aliases: `mit` = `mitigated`, `fixed` = `resolved`, `sev`/`setseverity` = `severity`, `edit` = `update`\n" ) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py index 01bc5984..9d18adcb 100644 --- a/src/firetower/slack_app/handlers/mitigated.py +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -106,7 +106,7 @@ def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) - serializer.save() incident.refresh_from_db() - mitigation_notes = f"\n\n---\n**Mitigation notes:**\n**Impact:** {impact}\n**Action items:** {todo}" + mitigation_notes = f"\n\nMitigation notes:\nImpact: {impact}\nAction items: {todo}" new_description = (incident.description or "") + mitigation_notes desc_serializer = IncidentWriteSerializer( instance=incident, data={"description": new_description}, partial=True diff --git a/src/firetower/slack_app/handlers/resolved.py b/src/firetower/slack_app/handlers/resolved.py index fbd1e671..ffb350e2 100644 --- a/src/firetower/slack_app/handlers/resolved.py +++ b/src/firetower/slack_app/handlers/resolved.py @@ -25,7 +25,9 @@ def _build_resolved_modal( ] initial_severity = next( (opt for opt in severity_options if opt["value"] == current_severity), - severity_options[2], + next( + opt for opt in severity_options if opt["value"] == IncidentSeverity.P2.value + ), ) captain_element: dict = { From daac0d378fd2e76666fd2c3853c5a31d0c9b3ff5 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 15:44:37 -0400 Subject: [PATCH 31/49] Notify user when mitigation notes fail to save --- src/firetower/slack_app/handlers/mitigated.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py index 9d18adcb..304393b9 100644 --- a/src/firetower/slack_app/handlers/mitigated.py +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -113,8 +113,12 @@ def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) - ) 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 marked as Mitigated, but failed to append mitigation notes: {desc_serializer.errors}", + ) + return + desc_serializer.save() client.chat_postMessage( channel=channel_id, From 8513540b1e12e2b8f7a199b25582f0b0a611ec98 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 15:55:24 -0400 Subject: [PATCH 32/49] Simplify help text by inlining aliases --- src/firetower/slack_app/handlers/help.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index 45695b42..8e760539 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -9,15 +9,13 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: f"Usage: `{cmd} <command>`\n\n" f"Available commands:\n" f" `{cmd} new` - Create a new incident\n" - f" `{cmd} update` - Update incident metadata\n" - f" `{cmd} mitigated` - Mark incident as mitigated\n" - f" `{cmd} resolved` - Mark incident as resolved\n" + f" `{cmd} update` - Update incident metadata (alias: `{cmd} edit`)\n" + f" `{cmd} mitigated` - Mark incident as mitigated (alias: `{cmd} mit`)\n" + f" `{cmd} resolved` - Mark incident as resolved (alias: `{cmd} fixed`)\n" f" `{cmd} reopen` - Reopen an incident\n" - f" `{cmd} severity <P0-P4>` - Change incident severity (alias: `sev`)\n" + f" `{cmd} severity <P0-P4>` - Change incident severity (alias: `{cmd} sev`)\n" f" `{cmd} subject <title>` - Change incident title\n" f" `{cmd} statuspage` - Statuspage (not yet implemented)\n" f" `{cmd} dumpslack` - Dump slack history (not yet implemented)\n" f" `{cmd} help` - Show this help message\n" - f"\n" - f"Aliases: `mit` = `mitigated`, `fixed` = `resolved`, `sev`/`setseverity` = `severity`, `edit` = `update`\n" ) From cc713c481b79f738730c54eed85a4c7bc727fcf3 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:16:06 -0400 Subject: [PATCH 33/49] Fix CI failures in update_incident handler and slack app tests --- src/firetower/incidents/tests/test_serializers.py | 1 - .../slack_app/handlers/update_incident.py | 5 +++-- src/firetower/slack_app/tests/test_handlers.py | 14 ++++++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/firetower/incidents/tests/test_serializers.py b/src/firetower/incidents/tests/test_serializers.py index 6d20c5f8..1707e690 100644 --- a/src/firetower/incidents/tests/test_serializers.py +++ b/src/firetower/incidents/tests/test_serializers.py @@ -154,7 +154,6 @@ class TestIncidentWriteSerializerHooks: def enable_hooks(self, settings): settings.HOOKS_ENABLED = True - def setup_method(self): self.captain = User.objects.create_user( username="captain@example.com", diff --git a/src/firetower/slack_app/handlers/update_incident.py b/src/firetower/slack_app/handlers/update_incident.py index 3cab7c31..e93e7da7 100644 --- a/src/firetower/slack_app/handlers/update_incident.py +++ b/src/firetower/slack_app/handlers/update_incident.py @@ -16,9 +16,10 @@ def _build_update_incident_modal(incident: Incident, channel_id: str) -> dict: } for sev in IncidentSeverity ] + current_severity = IncidentSeverity(incident.severity) current_severity_option = { - "text": {"type": "plain_text", "text": incident.severity.label}, - "value": incident.severity.value, + "text": {"type": "plain_text", "text": current_severity.label}, + "value": current_severity.value, } impact_type_initial = [ diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index aceb9232..2f7119b9 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -241,10 +241,16 @@ def test_unknown_user_returns_error(self, mock_get_user): handle_new_incident_submission(ack, body, view, client) - ack.assert_called_once() - call_kwargs = ack.call_args[1] - assert call_kwargs["response_action"] == "errors" + ack.assert_called_once_with() + client.chat_postMessage.assert_called_once() + msg = client.chat_postMessage.call_args[1]["text"] + assert "Could not identify" in msg + + @pytest.fixture(autouse=False) + def _enable_hooks(self, settings): + settings.HOOKS_ENABLED = True + @pytest.mark.usefixtures("_enable_hooks") @patch( "firetower.incidents.serializers.on_incident_created", side_effect=RuntimeError("boom"), @@ -277,4 +283,4 @@ def test_save_failure_sends_error_dm(self, mock_get_user, mock_hook): 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 + assert "Slack channel manually" in msg From c05125fbcc51edb2f5c71f39ff2f97ea37a60635 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:19:09 -0400 Subject: [PATCH 34/49] Add /ft captain command to set incident captain from Slack --- src/firetower/slack_app/bolt.py | 10 ++ src/firetower/slack_app/handlers/captain.py | 45 +++++++++ src/firetower/slack_app/handlers/help.py | 11 ++- .../slack_app/tests/test_channel_commands.py | 96 +++++++++++++++++++ 4 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 src/firetower/slack_app/handlers/captain.py diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 7283b475..5c688fd8 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -5,6 +5,7 @@ from django.conf import settings from slack_bolt import App +from firetower.slack_app.handlers.captain import handle_captain_command from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command from firetower.slack_app.handlers.help import handle_help_command from firetower.slack_app.handlers.mitigated import ( @@ -47,6 +48,8 @@ "subject", "update", "edit", + "captain", + "ic", "statuspage", "dumpslack", } @@ -106,6 +109,13 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: respond(f"Usage: `{cmd} subject <new title>`") else: handle_subject_command(ack, body, command, respond, new_subject=args) + elif subcommand in ("captain", "ic"): + if not args: + ack() + cmd = command.get("command", "/ft") + respond(f"Usage: `{cmd} captain @user`") + else: + handle_captain_command(ack, body, command, respond, user_mention=args) elif subcommand == "statuspage": handle_statuspage_command(ack, command, respond) elif subcommand == "dumpslack": diff --git a/src/firetower/slack_app/handlers/captain.py b/src/firetower/slack_app/handlers/captain.py new file mode 100644 index 00000000..31c0659c --- /dev/null +++ b/src/firetower/slack_app/handlers/captain.py @@ -0,0 +1,45 @@ +import logging +import re +from typing import Any + +from firetower.auth.services import get_or_create_user_from_slack_id +from firetower.incidents.serializers import IncidentWriteSerializer +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + +SLACK_USER_MENTION_RE = re.compile(r"<@(U[A-Z0-9]+)(?:\|[^>]*)?>") + + +def handle_captain_command( + ack: Any, body: dict, command: dict, respond: Any, user_mention: 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 + + match = SLACK_USER_MENTION_RE.search(user_mention) + if not match: + cmd = command.get("command", "/ft") + respond(f"Usage: `{cmd} captain @user`") + return + + slack_user_id = match.group(1) + user = get_or_create_user_from_slack_id(slack_user_id) + if not user: + respond(f"Could not resolve <@{slack_user_id}> to a Firetower user.") + return + + serializer = IncidentWriteSerializer( + instance=incident, data={"captain": user.email}, partial=True + ) + if serializer.is_valid(): + serializer.save() + respond( + f"{incident.incident_number} captain updated to {user.get_full_name()}." + ) + else: + respond(f"Failed to update captain: {serializer.errors}") diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index 8e760539..d85be326 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -8,14 +8,15 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: f"*Firetower Slack App*\n" f"Usage: `{cmd} <command>`\n\n" f"Available commands:\n" + f" `{cmd} help` - Show this help message\n" f" `{cmd} new` - Create a new incident\n" - f" `{cmd} update` - Update incident metadata (alias: `{cmd} edit`)\n" - f" `{cmd} mitigated` - Mark incident as mitigated (alias: `{cmd} mit`)\n" - f" `{cmd} resolved` - Mark incident as resolved (alias: `{cmd} fixed`)\n" - f" `{cmd} reopen` - Reopen an incident\n" f" `{cmd} severity <P0-P4>` - Change incident severity (alias: `{cmd} sev`)\n" f" `{cmd} subject <title>` - Change incident title\n" + f" `{cmd} captain @user` - Set incident captain (alias: `{cmd} ic`)\n" + f" `{cmd} update` - Interactively update incident metadata (alias: `{cmd} edit`)\n" + f" `{cmd} mitigated` - Mark incident as mitigated (alias: `{cmd} mit`)\n" + f" `{cmd} resolved` - Mark incident as resolved (alias: `{cmd} fixed`)\n" f" `{cmd} statuspage` - Statuspage (not yet implemented)\n" f" `{cmd} dumpslack` - Dump slack history (not yet implemented)\n" - f" `{cmd} help` - Show this help message\n" + f" `{cmd} reopen` - Reopen an incident\n" ) diff --git a/src/firetower/slack_app/tests/test_channel_commands.py b/src/firetower/slack_app/tests/test_channel_commands.py index e60b3d1e..44988153 100644 --- a/src/firetower/slack_app/tests/test_channel_commands.py +++ b/src/firetower/slack_app/tests/test_channel_commands.py @@ -12,6 +12,7 @@ IncidentStatus, ) from firetower.slack_app.bolt import handle_command +from firetower.slack_app.handlers.captain import handle_captain_command from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command from firetower.slack_app.handlers.mitigated import ( handle_mitigated_command, @@ -589,3 +590,98 @@ def test_returns_not_implemented(self): respond.assert_called_once() assert "not yet implemented" in respond.call_args[0][0] assert "/inc dumpslack" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestCaptainCommand: + @patch("firetower.incidents.serializers.on_captain_changed") + @patch("firetower.incidents.serializers.on_title_changed") + @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") + def test_sets_captain( + self, mock_get_user, mock_title_hook, mock_captain_hook, user, incident + ): + mock_get_user.return_value = user + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") + + ack.assert_called_once() + incident.refresh_from_db() + assert incident.captain == user + assert "captain updated" in respond.call_args[0][0] + + def test_no_user_mention(self, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_captain_command(ack, body, command, respond, user_mention="notamention") + + ack.assert_called_once() + assert "Usage" 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_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") + + ack.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") + def test_user_not_found(self, mock_get_user, incident): + mock_get_user.return_value = None + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_captain_command(ack, body, command, respond, user_mention="<@U_UNKNOWN>") + + ack.assert_called_once() + assert "Could not resolve" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestCaptainRouting: + @patch("firetower.slack_app.bolt.statsd") + def test_captain_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "captain <@U_CAPTAIN>", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + assert mock_handler.call_args[1]["user_mention"] == "<@U_CAPTAIN>" + + @patch("firetower.slack_app.bolt.statsd") + def test_ic_alias_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "ic <@U_CAPTAIN>", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_captain_no_arg_shows_usage(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "captain", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + handle_command(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + assert "Usage" in respond.call_args[0][0] From b3aa43c710395b4987a5fe8e418137c8c292bfcb Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:20:17 -0400 Subject: [PATCH 35/49] Reorder help text and simplify stub messages --- src/firetower/slack_app/handlers/dumpslack.py | 5 +---- src/firetower/slack_app/handlers/help.py | 4 ++-- src/firetower/slack_app/handlers/statuspage.py | 5 +---- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/firetower/slack_app/handlers/dumpslack.py b/src/firetower/slack_app/handlers/dumpslack.py index 79cdba1a..338227a3 100644 --- a/src/firetower/slack_app/handlers/dumpslack.py +++ b/src/firetower/slack_app/handlers/dumpslack.py @@ -3,7 +3,4 @@ def handle_dumpslack_command(ack: Any, command: dict, respond: Any) -> None: ack() - respond( - "Dumpslack is not yet implemented in Firetower." - " Use `/inc dumpslack` in the meantime." - ) + respond("Dumpslack is not yet implemented.") diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index d85be326..056a6122 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -16,7 +16,7 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: f" `{cmd} update` - Interactively update incident metadata (alias: `{cmd} edit`)\n" f" `{cmd} mitigated` - Mark incident as mitigated (alias: `{cmd} mit`)\n" f" `{cmd} resolved` - Mark incident as resolved (alias: `{cmd} fixed`)\n" - f" `{cmd} statuspage` - Statuspage (not yet implemented)\n" - f" `{cmd} dumpslack` - Dump slack history (not yet implemented)\n" + f" `{cmd} statuspage` - Create or update a statuspage post (not yet implemented)\n" + f" `{cmd} dumpslack` - Dump slack channel history (not yet implemented)\n" f" `{cmd} reopen` - Reopen an incident\n" ) diff --git a/src/firetower/slack_app/handlers/statuspage.py b/src/firetower/slack_app/handlers/statuspage.py index af48efaa..3da98843 100644 --- a/src/firetower/slack_app/handlers/statuspage.py +++ b/src/firetower/slack_app/handlers/statuspage.py @@ -3,7 +3,4 @@ def handle_statuspage_command(ack: Any, command: dict, respond: Any) -> None: ack() - respond( - "Statuspage is not yet implemented in Firetower." - " Use `/inc statuspage` in the meantime." - ) + respond("Statuspage is not yet implemented.") From ec2e8bcc2c96149adc5100df9849de0905ec2c20 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:20:49 -0400 Subject: [PATCH 36/49] Add args to help usage line --- src/firetower/slack_app/handlers/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index 056a6122..a2b90a74 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -6,7 +6,7 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: cmd = command.get("command", "/ft") respond( f"*Firetower Slack App*\n" - f"Usage: `{cmd} <command>`\n\n" + f"Usage: `{cmd} <command> [args]`\n\n" f"Available commands:\n" f" `{cmd} help` - Show this help message\n" f" `{cmd} new` - Create a new incident\n" From e958c3dd9d3ddfaea87a1590f52257f454567924 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:23:48 -0400 Subject: [PATCH 37/49] Fix stub command test assertions to match simplified messages --- src/firetower/slack_app/handlers/dumpslack.py | 3 ++- src/firetower/slack_app/handlers/statuspage.py | 3 ++- src/firetower/slack_app/tests/test_channel_commands.py | 2 -- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/firetower/slack_app/handlers/dumpslack.py b/src/firetower/slack_app/handlers/dumpslack.py index 338227a3..8c33a80f 100644 --- a/src/firetower/slack_app/handlers/dumpslack.py +++ b/src/firetower/slack_app/handlers/dumpslack.py @@ -3,4 +3,5 @@ def handle_dumpslack_command(ack: Any, command: dict, respond: Any) -> None: ack() - respond("Dumpslack is not yet implemented.") + cmd = command.get("command", "/ft") + respond(f"`{cmd} dumpslack` is not yet implemented.") diff --git a/src/firetower/slack_app/handlers/statuspage.py b/src/firetower/slack_app/handlers/statuspage.py index 3da98843..6f375b58 100644 --- a/src/firetower/slack_app/handlers/statuspage.py +++ b/src/firetower/slack_app/handlers/statuspage.py @@ -3,4 +3,5 @@ def handle_statuspage_command(ack: Any, command: dict, respond: Any) -> None: ack() - respond("Statuspage is not yet implemented.") + cmd = command.get("command", "/ft") + respond(f"`{cmd} statuspage` is not yet implemented.") diff --git a/src/firetower/slack_app/tests/test_channel_commands.py b/src/firetower/slack_app/tests/test_channel_commands.py index 44988153..d9e42f2b 100644 --- a/src/firetower/slack_app/tests/test_channel_commands.py +++ b/src/firetower/slack_app/tests/test_channel_commands.py @@ -575,7 +575,6 @@ def test_returns_not_implemented(self): ack.assert_called_once() respond.assert_called_once() assert "not yet implemented" in respond.call_args[0][0] - assert "/inc statuspage" in respond.call_args[0][0] class TestDumpslackCommand: @@ -589,7 +588,6 @@ def test_returns_not_implemented(self): ack.assert_called_once() respond.assert_called_once() assert "not yet implemented" in respond.call_args[0][0] - assert "/inc dumpslack" in respond.call_args[0][0] @pytest.mark.django_db From c78df9c240860e84f2464cdf26dfd3c7692cc8ae Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:31:50 -0400 Subject: [PATCH 38/49] Split handler tests into individual files --- .../slack_app/tests/handlers/conftest.py | 47 ++ .../slack_app/tests/handlers/test_captain.py | 103 +++ .../tests/handlers/test_dumpslack.py | 16 + .../tests/handlers/test_mitigated.py | 93 +++ .../slack_app/tests/handlers/test_reopen.py | 53 ++ .../slack_app/tests/handlers/test_resolved.py | 133 ++++ .../slack_app/tests/handlers/test_severity.py | 60 ++ .../tests/handlers/test_statuspage.py | 16 + .../slack_app/tests/handlers/test_subject.py | 35 + .../slack_app/tests/handlers/test_utils.py | 17 + src/firetower/slack_app/tests/test_bolt.py | 205 ++++++ .../slack_app/tests/test_channel_commands.py | 685 ------------------ .../slack_app/tests/test_handlers.py | 286 -------- 13 files changed, 778 insertions(+), 971 deletions(-) create mode 100644 src/firetower/slack_app/tests/handlers/conftest.py create mode 100644 src/firetower/slack_app/tests/handlers/test_captain.py create mode 100644 src/firetower/slack_app/tests/handlers/test_dumpslack.py create mode 100644 src/firetower/slack_app/tests/handlers/test_mitigated.py create mode 100644 src/firetower/slack_app/tests/handlers/test_reopen.py create mode 100644 src/firetower/slack_app/tests/handlers/test_resolved.py create mode 100644 src/firetower/slack_app/tests/handlers/test_severity.py create mode 100644 src/firetower/slack_app/tests/handlers/test_statuspage.py create mode 100644 src/firetower/slack_app/tests/handlers/test_subject.py create mode 100644 src/firetower/slack_app/tests/handlers/test_utils.py create mode 100644 src/firetower/slack_app/tests/test_bolt.py delete mode 100644 src/firetower/slack_app/tests/test_channel_commands.py delete mode 100644 src/firetower/slack_app/tests/test_handlers.py diff --git a/src/firetower/slack_app/tests/handlers/conftest.py b/src/firetower/slack_app/tests/handlers/conftest.py new file mode 100644 index 00000000..640d02a4 --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/conftest.py @@ -0,0 +1,47 @@ +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, +) + +CHANNEL_ID = "C_TEST_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 diff --git a/src/firetower/slack_app/tests/handlers/test_captain.py b/src/firetower/slack_app/tests/handlers/test_captain.py new file mode 100644 index 00000000..0026cf6f --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_captain.py @@ -0,0 +1,103 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from firetower.slack_app.bolt import handle_command +from firetower.slack_app.handlers.captain import handle_captain_command + +from .conftest import CHANNEL_ID + + +@pytest.mark.django_db +class TestCaptainCommand: + @patch("firetower.incidents.serializers.on_captain_changed") + @patch("firetower.incidents.serializers.on_title_changed") + @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") + def test_sets_captain( + self, mock_get_user, mock_title_hook, mock_captain_hook, user, incident + ): + mock_get_user.return_value = user + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") + + ack.assert_called_once() + incident.refresh_from_db() + assert incident.captain == user + assert "captain updated" in respond.call_args[0][0] + + def test_no_user_mention(self, incident): + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_captain_command(ack, body, command, respond, user_mention="notamention") + + ack.assert_called_once() + assert "Usage" 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_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") + + ack.assert_called_once() + assert "Could not find" in respond.call_args[0][0] + + @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") + def test_user_not_found(self, mock_get_user, incident): + mock_get_user.return_value = None + ack = MagicMock() + body = {"channel_id": CHANNEL_ID} + command = {"command": "/ft"} + respond = MagicMock() + + handle_captain_command(ack, body, command, respond, user_mention="<@U_UNKNOWN>") + + ack.assert_called_once() + assert "Could not resolve" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestCaptainRouting: + @patch("firetower.slack_app.bolt.statsd") + def test_captain_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "captain <@U_CAPTAIN>", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + assert mock_handler.call_args[1]["user_mention"] == "<@U_CAPTAIN>" + + @patch("firetower.slack_app.bolt.statsd") + def test_ic_alias_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "ic <@U_CAPTAIN>", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_captain_no_arg_shows_usage(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "captain", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + handle_command(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + assert "Usage" in respond.call_args[0][0] diff --git a/src/firetower/slack_app/tests/handlers/test_dumpslack.py b/src/firetower/slack_app/tests/handlers/test_dumpslack.py new file mode 100644 index 00000000..340998e2 --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_dumpslack.py @@ -0,0 +1,16 @@ +from unittest.mock import MagicMock + +from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command + + +class TestDumpslackCommand: + def test_returns_not_implemented(self): + ack = MagicMock() + respond = MagicMock() + command = {"command": "/ft"} + + handle_dumpslack_command(ack, command, respond) + + ack.assert_called_once() + respond.assert_called_once() + assert "not yet implemented" in respond.call_args[0][0] diff --git a/src/firetower/slack_app/tests/handlers/test_mitigated.py b/src/firetower/slack_app/tests/handlers/test_mitigated.py new file mode 100644 index 00000000..f9daff8d --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_mitigated.py @@ -0,0 +1,93 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from firetower.incidents.models import IncidentStatus +from firetower.slack_app.handlers.mitigated import ( + handle_mitigated_command, + handle_mitigated_submission, +) + +from .conftest import CHANNEL_ID + + +@pytest.mark.django_db +class TestMitigatedCommand: + @patch("firetower.slack_app.bolt.get_bolt_app") + def test_opens_modal(self, mock_get_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_get_bolt_app.return_value.client.views_open.assert_called_once() + view = mock_get_bolt_app.return_value.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() diff --git a/src/firetower/slack_app/tests/handlers/test_reopen.py b/src/firetower/slack_app/tests/handlers/test_reopen.py new file mode 100644 index 00000000..2f8ec10a --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_reopen.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from firetower.incidents.models import IncidentStatus +from firetower.slack_app.handlers.reopen import handle_reopen_command + +from .conftest import CHANNEL_ID + + +@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] diff --git a/src/firetower/slack_app/tests/handlers/test_resolved.py b/src/firetower/slack_app/tests/handlers/test_resolved.py new file mode 100644 index 00000000..c60904a6 --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_resolved.py @@ -0,0 +1,133 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from firetower.incidents.models import IncidentSeverity, IncidentStatus +from firetower.slack_app.handlers.resolved import ( + handle_resolved_command, + handle_resolved_submission, +) + +from .conftest import CHANNEL_ID + + +@pytest.mark.django_db +class TestResolvedCommand: + @patch("firetower.slack_app.bolt.get_bolt_app") + def test_opens_modal(self, mock_get_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_get_bolt_app.return_value.client.views_open.assert_called_once() + view = mock_get_bolt_app.return_value.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() diff --git a/src/firetower/slack_app/tests/handlers/test_severity.py b/src/firetower/slack_app/tests/handlers/test_severity.py new file mode 100644 index 00000000..e158473a --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_severity.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from firetower.incidents.models import IncidentSeverity +from firetower.slack_app.handlers.severity import handle_severity_command + +from .conftest import CHANNEL_ID + + +@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] diff --git a/src/firetower/slack_app/tests/handlers/test_statuspage.py b/src/firetower/slack_app/tests/handlers/test_statuspage.py new file mode 100644 index 00000000..f1c01481 --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_statuspage.py @@ -0,0 +1,16 @@ +from unittest.mock import MagicMock + +from firetower.slack_app.handlers.statuspage import handle_statuspage_command + + +class TestStatuspageCommand: + def test_returns_not_implemented(self): + ack = MagicMock() + respond = MagicMock() + command = {"command": "/ft"} + + handle_statuspage_command(ack, command, respond) + + ack.assert_called_once() + respond.assert_called_once() + assert "not yet implemented" in respond.call_args[0][0] diff --git a/src/firetower/slack_app/tests/handlers/test_subject.py b/src/firetower/slack_app/tests/handlers/test_subject.py new file mode 100644 index 00000000..8498a238 --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_subject.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from firetower.slack_app.handlers.subject import handle_subject_command + +from .conftest import CHANNEL_ID + + +@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] diff --git a/src/firetower/slack_app/tests/handlers/test_utils.py b/src/firetower/slack_app/tests/handlers/test_utils.py new file mode 100644 index 00000000..32f7c03d --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_utils.py @@ -0,0 +1,17 @@ +from firetower.slack_app.handlers.utils import get_incident_from_channel + +from .conftest import CHANNEL_ID + + +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 diff --git a/src/firetower/slack_app/tests/test_bolt.py b/src/firetower/slack_app/tests/test_bolt.py new file mode 100644 index 00000000..5c940397 --- /dev/null +++ b/src/firetower/slack_app/tests/test_bolt.py @@ -0,0 +1,205 @@ +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_command + +CHANNEL_ID = "C_TEST_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 + + +@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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(ack=ack, body=body, command=command, respond=respond) + + mock_statsd.increment.assert_any_call( + "slack_app.commands.submitted", tags=["subcommand:reopen"] + ) + + @patch("firetower.slack_app.bolt.statsd") + def test_statuspage_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "statuspage", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch( + "firetower.slack_app.bolt.handle_statuspage_command" + ) as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() + + @patch("firetower.slack_app.bolt.statsd") + def test_dumpslack_routes(self, mock_statsd, incident): + ack = MagicMock() + respond = MagicMock() + body = {"text": "dumpslack", "channel_id": CHANNEL_ID} + command = {"command": "/ft"} + + with patch("firetower.slack_app.bolt.handle_dumpslack_command") as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() diff --git a/src/firetower/slack_app/tests/test_channel_commands.py b/src/firetower/slack_app/tests/test_channel_commands.py deleted file mode 100644 index d9e42f2b..00000000 --- a/src/firetower/slack_app/tests/test_channel_commands.py +++ /dev/null @@ -1,685 +0,0 @@ -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_command -from firetower.slack_app.handlers.captain import handle_captain_command -from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command -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.statuspage import handle_statuspage_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.get_bolt_app") - def test_opens_modal(self, mock_get_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_get_bolt_app.return_value.client.views_open.assert_called_once() - view = mock_get_bolt_app.return_value.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.get_bolt_app") - def test_opens_modal(self, mock_get_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_get_bolt_app.return_value.client.views_open.assert_called_once() - view = mock_get_bolt_app.return_value.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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(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_command(ack=ack, body=body, command=command, respond=respond) - - mock_statsd.increment.assert_any_call( - "slack_app.commands.submitted", tags=["subcommand:reopen"] - ) - - @patch("firetower.slack_app.bolt.statsd") - def test_statuspage_routes(self, mock_statsd, incident): - ack = MagicMock() - respond = MagicMock() - body = {"text": "statuspage", "channel_id": CHANNEL_ID} - command = {"command": "/ft"} - - with patch( - "firetower.slack_app.bolt.handle_statuspage_command" - ) as mock_handler: - handle_command(ack=ack, body=body, command=command, respond=respond) - mock_handler.assert_called_once() - - @patch("firetower.slack_app.bolt.statsd") - def test_dumpslack_routes(self, mock_statsd, incident): - ack = MagicMock() - respond = MagicMock() - body = {"text": "dumpslack", "channel_id": CHANNEL_ID} - command = {"command": "/ft"} - - with patch("firetower.slack_app.bolt.handle_dumpslack_command") as mock_handler: - handle_command(ack=ack, body=body, command=command, respond=respond) - mock_handler.assert_called_once() - - -class TestStatuspageCommand: - def test_returns_not_implemented(self): - ack = MagicMock() - respond = MagicMock() - command = {"command": "/ft"} - - handle_statuspage_command(ack, command, respond) - - ack.assert_called_once() - respond.assert_called_once() - assert "not yet implemented" in respond.call_args[0][0] - - -class TestDumpslackCommand: - def test_returns_not_implemented(self): - ack = MagicMock() - respond = MagicMock() - command = {"command": "/ft"} - - handle_dumpslack_command(ack, command, respond) - - ack.assert_called_once() - respond.assert_called_once() - assert "not yet implemented" in respond.call_args[0][0] - - -@pytest.mark.django_db -class TestCaptainCommand: - @patch("firetower.incidents.serializers.on_captain_changed") - @patch("firetower.incidents.serializers.on_title_changed") - @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") - def test_sets_captain( - self, mock_get_user, mock_title_hook, mock_captain_hook, user, incident - ): - mock_get_user.return_value = user - ack = MagicMock() - body = {"channel_id": CHANNEL_ID} - command = {"command": "/ft"} - respond = MagicMock() - - handle_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") - - ack.assert_called_once() - incident.refresh_from_db() - assert incident.captain == user - assert "captain updated" in respond.call_args[0][0] - - def test_no_user_mention(self, incident): - ack = MagicMock() - body = {"channel_id": CHANNEL_ID} - command = {"command": "/ft"} - respond = MagicMock() - - handle_captain_command(ack, body, command, respond, user_mention="notamention") - - ack.assert_called_once() - assert "Usage" 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_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") - - ack.assert_called_once() - assert "Could not find" in respond.call_args[0][0] - - @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") - def test_user_not_found(self, mock_get_user, incident): - mock_get_user.return_value = None - ack = MagicMock() - body = {"channel_id": CHANNEL_ID} - command = {"command": "/ft"} - respond = MagicMock() - - handle_captain_command(ack, body, command, respond, user_mention="<@U_UNKNOWN>") - - ack.assert_called_once() - assert "Could not resolve" in respond.call_args[0][0] - - -@pytest.mark.django_db -class TestCaptainRouting: - @patch("firetower.slack_app.bolt.statsd") - def test_captain_routes(self, mock_statsd, incident): - ack = MagicMock() - respond = MagicMock() - body = {"text": "captain <@U_CAPTAIN>", "channel_id": CHANNEL_ID} - command = {"command": "/ft"} - - with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: - handle_command(ack=ack, body=body, command=command, respond=respond) - mock_handler.assert_called_once() - assert mock_handler.call_args[1]["user_mention"] == "<@U_CAPTAIN>" - - @patch("firetower.slack_app.bolt.statsd") - def test_ic_alias_routes(self, mock_statsd, incident): - ack = MagicMock() - respond = MagicMock() - body = {"text": "ic <@U_CAPTAIN>", "channel_id": CHANNEL_ID} - command = {"command": "/ft"} - - with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: - handle_command(ack=ack, body=body, command=command, respond=respond) - mock_handler.assert_called_once() - - @patch("firetower.slack_app.bolt.statsd") - def test_captain_no_arg_shows_usage(self, mock_statsd, incident): - ack = MagicMock() - respond = MagicMock() - body = {"text": "captain", "channel_id": CHANNEL_ID} - command = {"command": "/ft"} - - handle_command(ack=ack, body=body, command=command, respond=respond) - - ack.assert_called_once() - assert "Usage" in respond.call_args[0][0] diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py deleted file mode 100644 index 2f7119b9..00000000 --- a/src/firetower/slack_app/tests/test_handlers.py +++ /dev/null @@ -1,286 +0,0 @@ -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_command -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_command(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_command(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_command(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_command(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_command(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_command(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.get_bolt_app") - @patch("firetower.slack_app.bolt.statsd") - def test_new_subcommand_routes_correctly(self, mock_statsd, mock_get_bolt_app): - ack = MagicMock() - respond = MagicMock() - body = self._make_body(text="new") - body["trigger_id"] = "T12345" - command = self._make_command() - - handle_command(ack=ack, body=body, command=command, respond=respond) - - ack.assert_called_once() - mock_get_bolt_app.return_value.client.views_open.assert_called_once() - - -@pytest.mark.django_db -class TestNewIncidentModal: - @patch("firetower.slack_app.bolt.get_bolt_app") - def test_new_opens_modal(self, mock_get_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_get_bolt_app.return_value.client.views_open.assert_called_once() - view = mock_get_bolt_app.return_value.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_with() - client.chat_postMessage.assert_called_once() - msg = client.chat_postMessage.call_args[1]["text"] - assert "Could not identify" in msg - - @pytest.fixture(autouse=False) - def _enable_hooks(self, settings): - settings.HOOKS_ENABLED = True - - @pytest.mark.usefixtures("_enable_hooks") - @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 "Slack channel manually" in msg From 0b713b02ab6c1e83fa6d84e2528b312e71b5b8d0 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:39:51 -0400 Subject: [PATCH 39/49] Accept both mention format and plain user ID for captain command --- src/firetower/slack_app/handlers/captain.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/firetower/slack_app/handlers/captain.py b/src/firetower/slack_app/handlers/captain.py index 31c0659c..ab047502 100644 --- a/src/firetower/slack_app/handlers/captain.py +++ b/src/firetower/slack_app/handlers/captain.py @@ -9,6 +9,18 @@ logger = logging.getLogger(__name__) SLACK_USER_MENTION_RE = re.compile(r"<@(U[A-Z0-9]+)(?:\|[^>]*)?>") +SLACK_USER_ID_RE = re.compile(r"^(U[A-Z0-9]+)$") + + +def _parse_slack_user_id(text: str) -> str | None: + """Extract a Slack user ID from a mention (<@U123>) or plain ID (U123).""" + match = SLACK_USER_MENTION_RE.search(text) + if match: + return match.group(1) + match = SLACK_USER_ID_RE.match(text.strip()) + if match: + return match.group(1) + return None def handle_captain_command( @@ -21,13 +33,11 @@ def handle_captain_command( respond("Could not find an incident associated with this channel.") return - match = SLACK_USER_MENTION_RE.search(user_mention) - if not match: + slack_user_id = _parse_slack_user_id(user_mention) + if not slack_user_id: cmd = command.get("command", "/ft") respond(f"Usage: `{cmd} captain @user`") return - - slack_user_id = match.group(1) user = get_or_create_user_from_slack_id(slack_user_id) if not user: respond(f"Could not resolve <@{slack_user_id}> to a Firetower user.") From 2b21ec0c77584b6fe95c92861a28338b33b204db Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:41:53 -0400 Subject: [PATCH 40/49] Add debug logging for slash command parsing --- src/firetower/slack_app/bolt.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 5c688fd8..fab02076 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -81,6 +81,9 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: ) tags = [f"subcommand:{metric_subcommand}"] statsd.increment(f"{METRICS_PREFIX}.submitted", tags=tags) + logger.info( + "Slash command raw_text=%r subcommand=%r args=%r", raw_text, subcommand, args + ) try: if subcommand == "new": From c79f7b0e6f1dc7775d3522b4be3e316c77c1285b Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:53:30 -0400 Subject: [PATCH 41/49] Convert /ft captain from inline command to modal with user picker --- src/firetower/slack_app/bolt.py | 13 +- src/firetower/slack_app/handlers/captain.py | 124 +++++++++++---- .../slack_app/tests/handlers/test_captain.py | 141 +++++++++++++----- 3 files changed, 207 insertions(+), 71 deletions(-) diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index fab02076..7fe47b8c 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -5,7 +5,10 @@ from django.conf import settings from slack_bolt import App -from firetower.slack_app.handlers.captain import handle_captain_command +from firetower.slack_app.handlers.captain import ( + handle_captain_command, + handle_captain_submission, +) from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command from firetower.slack_app.handlers.help import handle_help_command from firetower.slack_app.handlers.mitigated import ( @@ -113,12 +116,7 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: else: handle_subject_command(ack, body, command, respond, new_subject=args) elif subcommand in ("captain", "ic"): - if not args: - ack() - cmd = command.get("command", "/ft") - respond(f"Usage: `{cmd} captain @user`") - else: - handle_captain_command(ack, body, command, respond, user_mention=args) + handle_captain_command(ack, body, command, respond) elif subcommand == "statuspage": handle_statuspage_command(ack, command, respond) elif subcommand == "dumpslack": @@ -142,6 +140,7 @@ def _register_views(app: App) -> None: app.view("update_incident_modal")(handle_update_incident_submission) app.view("mitigated_incident_modal")(handle_mitigated_submission) app.view("resolved_incident_modal")(handle_resolved_submission) + app.view("captain_incident_modal")(handle_captain_submission) for action_id in ( "impact_type_tags", "affected_service_tags", diff --git a/src/firetower/slack_app/handlers/captain.py b/src/firetower/slack_app/handlers/captain.py index ab047502..1be6ad86 100644 --- a/src/firetower/slack_app/handlers/captain.py +++ b/src/firetower/slack_app/handlers/captain.py @@ -1,31 +1,45 @@ import logging -import re 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.serializers import IncidentWriteSerializer from firetower.slack_app.handlers.utils import get_incident_from_channel logger = logging.getLogger(__name__) -SLACK_USER_MENTION_RE = re.compile(r"<@(U[A-Z0-9]+)(?:\|[^>]*)?>") -SLACK_USER_ID_RE = re.compile(r"^(U[A-Z0-9]+)$") +def _build_captain_modal( + incident_number: str, channel_id: str, captain_slack_id: str | None +) -> dict: + 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 -def _parse_slack_user_id(text: str) -> str | None: - """Extract a Slack user ID from a mention (<@U123>) or plain ID (U123).""" - match = SLACK_USER_MENTION_RE.search(text) - if match: - return match.group(1) - match = SLACK_USER_ID_RE.match(text.strip()) - if match: - return match.group(1) - return None + return { + "type": "modal", + "callback_id": "captain_incident_modal", + "private_metadata": channel_id, + "title": {"type": "plain_text", "text": incident_number}, + "submit": {"type": "plain_text", "text": "Update"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "captain_block", + "optional": True, + "element": captain_element, + "label": {"type": "plain_text", "text": "Incident Captain"}, + }, + ], + } -def handle_captain_command( - ack: Any, body: dict, command: dict, respond: Any, user_mention: str -) -> None: +def handle_captain_command(ack: Any, body: dict, command: dict, respond: Any) -> None: ack() channel_id = body.get("channel_id", "") incident = get_incident_from_channel(channel_id) @@ -33,23 +47,75 @@ def handle_captain_command( respond("Could not find an incident associated with this channel.") return - slack_user_id = _parse_slack_user_id(user_mention) - if not slack_user_id: - cmd = command.get("command", "/ft") - respond(f"Usage: `{cmd} captain @user`") + trigger_id = body.get("trigger_id") + if not trigger_id: + respond("Could not open modal — missing trigger_id.") return - user = get_or_create_user_from_slack_id(slack_user_id) - if not user: - respond(f"Could not resolve <@{slack_user_id}> to a Firetower user.") + + 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 get_bolt_app # noqa: PLC0415 + + get_bolt_app().client.views_open( + trigger_id=trigger_id, + view=_build_captain_modal( + incident.incident_number, channel_id, captain_slack_id + ), + ) + + +def handle_captain_submission(ack: Any, body: dict, view: dict, client: Any) -> None: + values = view.get("state", {}).get("values", {}) + channel_id = view.get("private_metadata", "") + + captain_slack_id = ( + values.get("captain_block", {}).get("captain_select", {}).get("selected_user") + ) + + ack() + + incident = get_incident_from_channel(channel_id) + if not incident: + logger.error("Captain submission: no incident for channel %s", channel_id) + return + + if not captain_slack_id: + client.chat_postMessage( + channel=channel_id, + text=f"*{incident.incident_number}* captain was not changed.", + ) + 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 serializer = IncidentWriteSerializer( - instance=incident, data={"captain": user.email}, partial=True + instance=incident, data={"captain": captain_user.email}, partial=True ) - if serializer.is_valid(): - serializer.save() - respond( - f"{incident.incident_number} captain updated to {user.get_full_name()}." + if not serializer.is_valid(): + logger.error("Captain update failed: %s", serializer.errors) + client.chat_postMessage( + channel=channel_id, + text=f"Failed to update captain: {serializer.errors}", ) - else: - respond(f"Failed to update captain: {serializer.errors}") + return + + serializer.save() + client.chat_postMessage( + channel=channel_id, + text=f"{incident.incident_number} captain updated to {captain_user.get_full_name()}.", + ) diff --git a/src/firetower/slack_app/tests/handlers/test_captain.py b/src/firetower/slack_app/tests/handlers/test_captain.py index 0026cf6f..7881e171 100644 --- a/src/firetower/slack_app/tests/handlers/test_captain.py +++ b/src/firetower/slack_app/tests/handlers/test_captain.py @@ -3,66 +3,135 @@ import pytest from firetower.slack_app.bolt import handle_command -from firetower.slack_app.handlers.captain import handle_captain_command +from firetower.slack_app.handlers.captain import ( + handle_captain_command, + handle_captain_submission, +) from .conftest import CHANNEL_ID @pytest.mark.django_db class TestCaptainCommand: - @patch("firetower.incidents.serializers.on_captain_changed") - @patch("firetower.incidents.serializers.on_title_changed") - @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") - def test_sets_captain( - self, mock_get_user, mock_title_hook, mock_captain_hook, user, incident - ): - mock_get_user.return_value = user + def test_opens_modal(self, incident): ack = MagicMock() - body = {"channel_id": CHANNEL_ID} + body = { + "channel_id": CHANNEL_ID, + "trigger_id": "T123", + } command = {"command": "/ft"} respond = MagicMock() - handle_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") + with patch("firetower.slack_app.handlers.captain.get_bolt_app") as mock_app: + handle_captain_command(ack, body, command, respond) - ack.assert_called_once() - incident.refresh_from_db() - assert incident.captain == user - assert "captain updated" in respond.call_args[0][0] + ack.assert_called_once() + mock_app().client.views_open.assert_called_once() + view = mock_app().client.views_open.call_args[1]["view"] + assert view["callback_id"] == "captain_incident_modal" - def test_no_user_mention(self, incident): + def test_prefills_current_captain(self, user, incident): ack = MagicMock() - body = {"channel_id": CHANNEL_ID} + body = { + "channel_id": CHANNEL_ID, + "trigger_id": "T123", + } command = {"command": "/ft"} respond = MagicMock() - handle_captain_command(ack, body, command, respond, user_mention="notamention") + with patch("firetower.slack_app.handlers.captain.get_bolt_app") as mock_app: + handle_captain_command(ack, body, command, respond) - ack.assert_called_once() - assert "Usage" in respond.call_args[0][0] + view = mock_app().client.views_open.call_args[1]["view"] + captain_element = view["blocks"][0]["element"] + assert captain_element["initial_user"] == "U_CAPTAIN" def test_no_incident_responds_error(self, db): ack = MagicMock() - body = {"channel_id": "C_UNKNOWN"} + body = {"channel_id": "C_UNKNOWN", "trigger_id": "T123"} command = {"command": "/ft"} respond = MagicMock() - handle_captain_command(ack, body, command, respond, user_mention="<@U_CAPTAIN>") + handle_captain_command(ack, body, command, respond) ack.assert_called_once() assert "Could not find" in respond.call_args[0][0] - @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") - def test_user_not_found(self, mock_get_user, incident): - mock_get_user.return_value = None + def test_no_trigger_id(self, incident): ack = MagicMock() body = {"channel_id": CHANNEL_ID} command = {"command": "/ft"} respond = MagicMock() - handle_captain_command(ack, body, command, respond, user_mention="<@U_UNKNOWN>") + handle_captain_command(ack, body, command, respond) + + ack.assert_called_once() + assert "trigger_id" in respond.call_args[0][0] + + +@pytest.mark.django_db +class TestCaptainSubmission: + @patch("firetower.incidents.serializers.on_captain_changed") + @patch("firetower.incidents.serializers.on_title_changed") + @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") + def test_sets_captain( + self, mock_get_user, mock_title_hook, mock_captain_hook, user, incident + ): + mock_get_user.return_value = user + ack = MagicMock() + body = {"user": {"id": "U_SUBMITTER"}} + view = { + "state": { + "values": { + "captain_block": {"captain_select": {"selected_user": "U_CAPTAIN"}} + } + }, + "private_metadata": CHANNEL_ID, + } + client = MagicMock() + + handle_captain_submission(ack, body, view, client) + + ack.assert_called_once() + incident.refresh_from_db() + assert incident.captain == user + assert "captain updated" in client.chat_postMessage.call_args[1]["text"] + + @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") + def test_user_not_found(self, mock_get_user, incident): + mock_get_user.return_value = None + ack = MagicMock() + body = {"user": {"id": "U_SUBMITTER"}} + view = { + "state": { + "values": { + "captain_block": {"captain_select": {"selected_user": "U_UNKNOWN"}} + } + }, + "private_metadata": CHANNEL_ID, + } + client = MagicMock() + + handle_captain_submission(ack, body, view, client) + + ack.assert_called_once() + assert "Failed to resolve" in client.chat_postMessage.call_args[1]["text"] + + def test_no_captain_selected(self, incident): + ack = MagicMock() + body = {"user": {"id": "U_SUBMITTER"}} + view = { + "state": { + "values": {"captain_block": {"captain_select": {"selected_user": None}}} + }, + "private_metadata": CHANNEL_ID, + } + client = MagicMock() + + handle_captain_submission(ack, body, view, client) ack.assert_called_once() - assert "Could not resolve" in respond.call_args[0][0] + assert "not changed" in client.chat_postMessage.call_args[1]["text"] @pytest.mark.django_db @@ -71,19 +140,18 @@ class TestCaptainRouting: def test_captain_routes(self, mock_statsd, incident): ack = MagicMock() respond = MagicMock() - body = {"text": "captain <@U_CAPTAIN>", "channel_id": CHANNEL_ID} + body = {"text": "captain", "channel_id": CHANNEL_ID, "trigger_id": "T123"} command = {"command": "/ft"} with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: handle_command(ack=ack, body=body, command=command, respond=respond) mock_handler.assert_called_once() - assert mock_handler.call_args[1]["user_mention"] == "<@U_CAPTAIN>" @patch("firetower.slack_app.bolt.statsd") def test_ic_alias_routes(self, mock_statsd, incident): ack = MagicMock() respond = MagicMock() - body = {"text": "ic <@U_CAPTAIN>", "channel_id": CHANNEL_ID} + body = {"text": "ic", "channel_id": CHANNEL_ID, "trigger_id": "T123"} command = {"command": "/ft"} with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: @@ -91,13 +159,16 @@ def test_ic_alias_routes(self, mock_statsd, incident): mock_handler.assert_called_once() @patch("firetower.slack_app.bolt.statsd") - def test_captain_no_arg_shows_usage(self, mock_statsd, incident): + def test_captain_with_args_still_routes(self, mock_statsd, incident): ack = MagicMock() respond = MagicMock() - body = {"text": "captain", "channel_id": CHANNEL_ID} + body = { + "text": "captain <@U_CAPTAIN>", + "channel_id": CHANNEL_ID, + "trigger_id": "T123", + } command = {"command": "/ft"} - handle_command(ack=ack, body=body, command=command, respond=respond) - - ack.assert_called_once() - assert "Usage" in respond.call_args[0][0] + with patch("firetower.slack_app.bolt.handle_captain_command") as mock_handler: + handle_command(ack=ack, body=body, command=command, respond=respond) + mock_handler.assert_called_once() From 4acea151e573ff2bd42c2f5b4564c6aeafce049e Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:53:35 -0400 Subject: [PATCH 42/49] Add captain selector to the new incident modal --- .../slack_app/handlers/new_incident.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/firetower/slack_app/handlers/new_incident.py b/src/firetower/slack_app/handlers/new_incident.py index 568189da..7b816f4c 100644 --- a/src/firetower/slack_app/handlers/new_incident.py +++ b/src/firetower/slack_app/handlers/new_incident.py @@ -128,6 +128,23 @@ def _build_new_incident_modal(channel_id: str = "") -> dict: }, ] + blocks.append( + { + "type": "input", + "block_id": "captain_block", + "optional": True, + "element": { + "type": "users_select", + "action_id": "captain_select", + "placeholder": { + "type": "plain_text", + "text": "Select incident captain", + }, + }, + "label": {"type": "plain_text", "text": "Incident Captain"}, + } + ) + blocks.append( { "type": "input", @@ -247,6 +264,10 @@ def handle_new_incident_submission( ) affected_region_tags = [opt["value"] for opt in affected_region_selections] + captain_slack_id = ( + values.get("captain_block", {}).get("captain_select", {}).get("selected_user") + ) + private_selections = ( values.get("private_block", {}).get("is_private", {}).get("selected_options") or [] @@ -271,12 +292,18 @@ def handle_new_incident_submission( ) return + captain_email = user.email + if captain_slack_id: + captain_user = get_or_create_user_from_slack_id(captain_slack_id) + if captain_user: + captain_email = captain_user.email + data = { "title": title, "severity": severity, "description": description, "impact_summary": impact_summary, - "captain": user.email, + "captain": captain_email, "reporter": user.email, "is_private": is_private, } From 96c2ee9e0cdeaea5d87c48fa6e456a47104f1897 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:53:41 -0400 Subject: [PATCH 43/49] Add captain selector to the update incident modal --- .../slack_app/handlers/update_incident.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/firetower/slack_app/handlers/update_incident.py b/src/firetower/slack_app/handlers/update_incident.py index e93e7da7..b6db38dc 100644 --- a/src/firetower/slack_app/handlers/update_incident.py +++ b/src/firetower/slack_app/handlers/update_incident.py @@ -1,6 +1,8 @@ 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 Incident, IncidentSeverity from firetower.incidents.serializers import IncidentWriteSerializer from firetower.slack_app.handlers.utils import get_incident_from_channel @@ -98,6 +100,18 @@ def _build_update_incident_modal(incident: Incident, channel_id: str) -> dict: if affected_region_initial: affected_region_element["initial_options"] = affected_region_initial + captain_element: dict[str, Any] = { + "type": "users_select", + "action_id": "captain_select", + "placeholder": {"type": "plain_text", "text": "Select incident captain"}, + } + if incident.captain: + slack_profile = incident.captain.external_profiles.filter( + type=ExternalProfileType.SLACK + ).first() + if slack_profile: + captain_element["initial_user"] = slack_profile.external_id + private_element: dict[str, Any] = { "type": "checkboxes", "action_id": "is_private", @@ -164,6 +178,13 @@ def _build_update_incident_modal(incident: Incident, channel_id: str) -> dict: "element": affected_region_element, "label": {"type": "plain_text", "text": "Affected Region"}, }, + { + "type": "input", + "block_id": "captain_block", + "optional": True, + "element": captain_element, + "label": {"type": "plain_text", "text": "Incident Captain"}, + }, { "type": "input", "block_id": "private_block", @@ -250,6 +271,10 @@ def handle_update_incident_submission( ) affected_region_tags = [opt["value"] for opt in affected_region_selections] + captain_slack_id = ( + values.get("captain_block", {}).get("captain_select", {}).get("selected_user") + ) + private_selections = ( values.get("private_block", {}).get("is_private", {}).get("selected_options") or [] @@ -281,6 +306,11 @@ def handle_update_incident_submission( "affected_region_tags": affected_region_tags, } + if captain_slack_id: + captain_user = get_or_create_user_from_slack_id(captain_slack_id) + if captain_user: + data["captain"] = captain_user.email + serializer = IncidentWriteSerializer(instance=incident, data=data, partial=True) if not serializer.is_valid(): logger.error("Incident update validation failed: %s", serializer.errors) From 7bd340aed245c47e7b186bc849802000740bc225 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:54:14 -0400 Subject: [PATCH 44/49] Remove debug logging for slash command parsing --- src/firetower/slack_app/bolt.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 7fe47b8c..3914d2ef 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -84,9 +84,6 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None: ) tags = [f"subcommand:{metric_subcommand}"] statsd.increment(f"{METRICS_PREFIX}.submitted", tags=tags) - logger.info( - "Slash command raw_text=%r subcommand=%r args=%r", raw_text, subcommand, args - ) try: if subcommand == "new": From 5b3fd464798c8e7be1cdf28d9cb296dcb3a41e65 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:58:51 -0400 Subject: [PATCH 45/49] Remove description append from mitigated handler, just post to Slack --- src/firetower/slack_app/handlers/mitigated.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py index 304393b9..182b0b5a 100644 --- a/src/firetower/slack_app/handlers/mitigated.py +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -105,21 +105,6 @@ def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) - return serializer.save() - incident.refresh_from_db() - mitigation_notes = f"\n\nMitigation notes:\nImpact: {impact}\nAction 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) - client.chat_postMessage( - channel=channel_id, - text=f"Incident marked as Mitigated, but failed to append mitigation notes: {desc_serializer.errors}", - ) - return - desc_serializer.save() - client.chat_postMessage( channel=channel_id, text=( From a6075addc2996a0f80b42071c83ab469131a1a3c Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 16:59:22 -0400 Subject: [PATCH 46/49] Match opsbot Slack message format for mitigated handler --- src/firetower/slack_app/handlers/mitigated.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/firetower/slack_app/handlers/mitigated.py b/src/firetower/slack_app/handlers/mitigated.py index 182b0b5a..12b433b5 100644 --- a/src/firetower/slack_app/handlers/mitigated.py +++ b/src/firetower/slack_app/handlers/mitigated.py @@ -1,6 +1,8 @@ import logging from typing import Any +from django.conf import settings + from firetower.incidents.models import IncidentStatus from firetower.incidents.serializers import IncidentWriteSerializer from firetower.slack_app.handlers.utils import get_incident_from_channel @@ -105,11 +107,14 @@ def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) - return serializer.save() + incident_url = f"{settings.FIRETOWER_BASE_URL}/{incident.incident_number}" client.chat_postMessage( channel=channel_id, text=( - f"*{incident.incident_number} marked as Mitigated*\n" - f"*Impact:* {impact}\n" - f"*Action items:* {todo}" + f"<{incident_url}|{incident.incident_number}> has been marked Mitigated.\n" + f"*Current Impact*:\n" + f"```{impact}```\n" + f"*Remaining Action Items*:\n" + f"```{todo}```" ), ) From 111a999726c6fec24ff29717e62627b0e2202b28 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Tue, 14 Apr 2026 17:02:20 -0400 Subject: [PATCH 47/49] Update captain help text to reflect modal-based flow --- src/firetower/slack_app/handlers/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index a2b90a74..95607320 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -12,7 +12,7 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: f" `{cmd} new` - Create a new incident\n" f" `{cmd} severity <P0-P4>` - Change incident severity (alias: `{cmd} sev`)\n" f" `{cmd} subject <title>` - Change incident title\n" - f" `{cmd} captain @user` - Set incident captain (alias: `{cmd} ic`)\n" + f" `{cmd} captain` - Set incident captain (alias: `{cmd} ic`)\n" f" `{cmd} update` - Interactively update incident metadata (alias: `{cmd} edit`)\n" f" `{cmd} mitigated` - Mark incident as mitigated (alias: `{cmd} mit`)\n" f" `{cmd} resolved` - Mark incident as resolved (alias: `{cmd} fixed`)\n" From d04cee08204560c981ea526c21b4aeb082a9bd46 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Wed, 15 Apr 2026 13:46:35 -0400 Subject: [PATCH 48/49] Couple review tweaks --- src/firetower/slack_app/handlers/captain.py | 4 ---- .../slack_app/tests/handlers/test_captain.py | 12 ++++++------ .../slack_app/tests/handlers/test_mitigated.py | 4 ++-- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/firetower/slack_app/handlers/captain.py b/src/firetower/slack_app/handlers/captain.py index 1be6ad86..c90afb15 100644 --- a/src/firetower/slack_app/handlers/captain.py +++ b/src/firetower/slack_app/handlers/captain.py @@ -115,7 +115,3 @@ def handle_captain_submission(ack: Any, body: dict, view: dict, client: Any) -> return serializer.save() - client.chat_postMessage( - channel=channel_id, - text=f"{incident.incident_number} captain updated to {captain_user.get_full_name()}.", - ) diff --git a/src/firetower/slack_app/tests/handlers/test_captain.py b/src/firetower/slack_app/tests/handlers/test_captain.py index 7881e171..8f012208 100644 --- a/src/firetower/slack_app/tests/handlers/test_captain.py +++ b/src/firetower/slack_app/tests/handlers/test_captain.py @@ -22,12 +22,12 @@ def test_opens_modal(self, incident): command = {"command": "/ft"} respond = MagicMock() - with patch("firetower.slack_app.handlers.captain.get_bolt_app") as mock_app: + with patch("firetower.slack_app.bolt.get_bolt_app") as mock_app: handle_captain_command(ack, body, command, respond) ack.assert_called_once() - mock_app().client.views_open.assert_called_once() - view = mock_app().client.views_open.call_args[1]["view"] + mock_app.return_value.client.views_open.assert_called_once() + view = mock_app.return_value.client.views_open.call_args[1]["view"] assert view["callback_id"] == "captain_incident_modal" def test_prefills_current_captain(self, user, incident): @@ -39,10 +39,10 @@ def test_prefills_current_captain(self, user, incident): command = {"command": "/ft"} respond = MagicMock() - with patch("firetower.slack_app.handlers.captain.get_bolt_app") as mock_app: + with patch("firetower.slack_app.bolt.get_bolt_app") as mock_app: handle_captain_command(ack, body, command, respond) - view = mock_app().client.views_open.call_args[1]["view"] + view = mock_app.return_value.client.views_open.call_args[1]["view"] captain_element = view["blocks"][0]["element"] assert captain_element["initial_user"] == "U_CAPTAIN" @@ -95,7 +95,7 @@ def test_sets_captain( ack.assert_called_once() incident.refresh_from_db() assert incident.captain == user - assert "captain updated" in client.chat_postMessage.call_args[1]["text"] + client.chat_postMessage.assert_not_called() @patch("firetower.slack_app.handlers.captain.get_or_create_user_from_slack_id") def test_user_not_found(self, mock_get_user, incident): diff --git a/src/firetower/slack_app/tests/handlers/test_mitigated.py b/src/firetower/slack_app/tests/handlers/test_mitigated.py index f9daff8d..b405cae9 100644 --- a/src/firetower/slack_app/tests/handlers/test_mitigated.py +++ b/src/firetower/slack_app/tests/handlers/test_mitigated.py @@ -67,11 +67,11 @@ def test_transitions_to_mitigated( 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 + assert "Reduced impact" in msg + assert "Monitor overnight" in msg def test_missing_incident_does_not_crash(self, db): ack = MagicMock() From e3200fc728565ecdbdd20d931424ffb2ca894fd5 Mon Sep 17 00:00:00 2001 From: Spencer Murray <spencer.murray@sentry.io> Date: Wed, 15 Apr 2026 14:02:10 -0400 Subject: [PATCH 49/49] Tweak modals --- .../slack_app/handlers/new_incident.py | 38 +++++++++---------- .../slack_app/handlers/update_incident.py | 14 +++---- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/firetower/slack_app/handlers/new_incident.py b/src/firetower/slack_app/handlers/new_incident.py index 7b816f4c..9c8bbff1 100644 --- a/src/firetower/slack_app/handlers/new_incident.py +++ b/src/firetower/slack_app/handlers/new_incident.py @@ -15,7 +15,7 @@ _DEFAULT_SEVERITY = IncidentSeverity.P3 -def _build_new_incident_modal(channel_id: str = "") -> dict: +def _build_new_incident_modal(channel_id: str = "", user_id: str = "") -> dict: severity_options = [ { "text": {"type": "plain_text", "text": sev.label}, @@ -29,6 +29,21 @@ def _build_new_incident_modal(channel_id: str = "") -> dict: } blocks = [ + { + "type": "input", + "block_id": "captain_block", + "optional": True, + "element": { + "type": "users_select", + "action_id": "captain_select", + "placeholder": { + "type": "plain_text", + "text": "Select incident captain", + }, + **({"initial_user": user_id} if user_id else {}), + }, + "label": {"type": "plain_text", "text": "Incident Captain"}, + }, { "type": "input", "block_id": "severity_block", @@ -128,23 +143,6 @@ def _build_new_incident_modal(channel_id: str = "") -> dict: }, ] - blocks.append( - { - "type": "input", - "block_id": "captain_block", - "optional": True, - "element": { - "type": "users_select", - "action_id": "captain_select", - "placeholder": { - "type": "plain_text", - "text": "Select incident captain", - }, - }, - "label": {"type": "plain_text", "text": "Incident Captain"}, - } - ) - blocks.append( { "type": "input", @@ -212,11 +210,13 @@ def handle_new_command(ack: Any, body: dict, command: dict, respond: Any) -> Non return channel_id = body.get("channel_id", "") + user_id = body.get("user_id", "") from firetower.slack_app.bolt import get_bolt_app # noqa: PLC0415 get_bolt_app().client.views_open( - trigger_id=trigger_id, view=_build_new_incident_modal(channel_id=channel_id) + trigger_id=trigger_id, + view=_build_new_incident_modal(channel_id=channel_id, user_id=user_id), ) diff --git a/src/firetower/slack_app/handlers/update_incident.py b/src/firetower/slack_app/handlers/update_incident.py index b6db38dc..1d1e2540 100644 --- a/src/firetower/slack_app/handlers/update_incident.py +++ b/src/firetower/slack_app/handlers/update_incident.py @@ -131,6 +131,13 @@ def _build_update_incident_modal(incident: Incident, channel_id: str) -> dict: ] blocks = [ + { + "type": "input", + "block_id": "captain_block", + "optional": True, + "element": captain_element, + "label": {"type": "plain_text", "text": "Incident Captain"}, + }, { "type": "input", "block_id": "severity_block", @@ -178,13 +185,6 @@ def _build_update_incident_modal(incident: Incident, channel_id: str) -> dict: "element": affected_region_element, "label": {"type": "plain_text", "text": "Affected Region"}, }, - { - "type": "input", - "block_id": "captain_block", - "optional": True, - "element": captain_element, - "label": {"type": "plain_text", "text": "Incident Captain"}, - }, { "type": "input", "block_id": "private_block",