diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index b192bbc2..1d8594c4 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -90,6 +90,7 @@ class ChannelSetupContext: title: str severity: str is_private: bool + skip_paging: bool = False captain_slack_id: str | None = None captain_name: str | None = None reporter_slack_id: str | None = None @@ -108,6 +109,7 @@ def page_for_channel( links: list[dict[str, str]] | None = None, channel_id: str | None = None, is_private: bool = False, + skip_paging: bool = False, ) -> set[str]: """Trigger PD pages for pageable severities. No DB access. @@ -115,7 +117,7 @@ def page_for_channel( """ paged: set[str] = set() - if is_private or severity not in HIGH_SEVERITIES: + if is_private or skip_paging or severity not in HIGH_SEVERITIES: return paged pd_config = settings.PAGERDUTY @@ -168,7 +170,12 @@ def page_for_channel( return paged -def _page_if_needed(incident: Incident, channel_id: str | None = None) -> set[str]: +def _page_if_needed( + incident: Incident, + channel_id: str | None = None, + *, + skip_paging: bool = False, +) -> set[str]: links: list[dict[str, str]] = [ {"href": _build_incident_url(incident), "text": "View in Firetower"} ] @@ -187,6 +194,7 @@ def _page_if_needed(incident: Incident, channel_id: str | None = None) -> set[st links=links, channel_id=channel_id, is_private=incident.is_private, + skip_paging=skip_paging, ) @@ -307,10 +315,11 @@ def _invite_oncall_to_channel( slack_service: SlackService, *, is_private: bool = False, + skip_paging: bool = False, paged_policies: set[str] | None = None, ) -> None: """Invite on-call users to a channel. No DB access.""" - if is_private or severity not in HIGH_SEVERITIES: + if is_private or skip_paging or severity not in HIGH_SEVERITIES: return pd_config = settings.PAGERDUTY @@ -900,6 +909,7 @@ def decorate_incident_channel( ctx.channel_id, slack_service, is_private=ctx.is_private, + skip_paging=ctx.skip_paging, paged_policies=paged_policies, ) except Exception: @@ -1178,7 +1188,7 @@ def schedule_statuspage_followup_reminder( ) -def on_incident_created(incident: Incident) -> None: +def on_incident_created(incident: Incident, *, skip_paging: bool = False) -> None: # Use get_or_create to atomically claim the ExternalLink row before calling # the Slack API. If two concurrent requests both reach this point, only one # will get created=True; the other bails out without creating a second channel. @@ -1220,14 +1230,11 @@ def on_incident_created(incident: Incident) -> None: f"Failed to create Slack channel for incident {incident.id}" ) - # Page P0/P1 early so on-call responders are alerted before we decorate the - # Slack channel. _page_if_needed reads the Slack link URL from the DB - # (already saved above), so the PD payload is complete even if channel_id - # is None here; channel_id is only used to post a fallback warning back to - # Slack if PD fails. paged_policies: set[str] = set() try: - paged_policies = _page_if_needed(incident, channel_id=channel_id) + paged_policies = _page_if_needed( + incident, channel_id=channel_id, skip_paging=skip_paging + ) except Exception: logger.exception(f"Failed to page for incident {incident.id}") @@ -1264,6 +1271,7 @@ def on_incident_created(incident: Incident) -> None: title=incident.title, severity=incident.severity, is_private=incident.is_private, + skip_paging=skip_paging, captain_slack_id=captain_slack_id, captain_name=captain_name, reporter_slack_id=reporter_slack_id, diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index 3bde103f..a9c2dc26 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -537,7 +537,9 @@ def create(self, validated_data: dict) -> Incident: self._auto_compute_downtime(incident, validated_data) if settings.HOOKS_ENABLED and not self.context.get("skip_hooks"): - on_incident_created(incident) + on_incident_created( + incident, skip_paging=self.context.get("skip_paging", False) + ) return incident diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index 6956cae0..30ad0e37 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -12,6 +12,7 @@ DEFAULT_STATUSPAGE_WARNING_BUFFER_MINUTES, _create_status_channel, _create_troubleshooting_doc, + _invite_oncall_to_channel, _invite_oncall_users, _linear_issue_title, _page_if_needed, @@ -955,6 +956,21 @@ def test_private_incident_does_not_page(self, mock_pd_cls, settings): mock_pd_cls.assert_not_called() assert result == set() + @patch("firetower.incidents.hooks.PagerDutyService") + def test_skip_paging_does_not_page(self, mock_pd_cls, settings): + settings.PAGERDUTY = MOCK_PD_CONFIG + settings.FIRETOWER_BASE_URL = "https://firetower.example.com" + + incident = Incident.objects.create( + title="Self-handled outage", + severity=IncidentSeverity.P0, + ) + + result = _page_if_needed(incident, skip_paging=True) + + mock_pd_cls.assert_not_called() + assert result == set() + @patch("firetower.incidents.hooks.PagerDutyService") def test_pages_only_configured_policies(self, mock_pd_cls, settings): settings.PAGERDUTY = { @@ -1028,6 +1044,23 @@ def test_returns_partial_set_on_mixed_results(self, mock_pd_cls, settings): assert result == {"IMOC"} + @patch("firetower.incidents.hooks.PagerDutyService") + def test_returns_empty_set_when_skip_paging(self, mock_pd_cls, settings): + settings.PAGERDUTY = MOCK_PD_CONFIG + mock_slack = MagicMock() + + result = page_for_channel( + IncidentSeverity.P0, + "INC-100", + "Self-handled outage", + mock_slack, + channel_id="C12345", + skip_paging=True, + ) + + assert result == set() + mock_pd_cls.assert_not_called() + @patch("firetower.incidents.hooks.PagerDutyService") def test_returns_empty_set_for_non_pageable_severity(self, mock_pd_cls, settings): settings.PAGERDUTY = MOCK_PD_CONFIG @@ -1059,7 +1092,26 @@ def test_pages_high_sev_on_p0_creation(self, mock_slack, mock_page): on_incident_created(incident) - mock_page.assert_called_once_with(incident, channel_id="C99999") + mock_page.assert_called_once_with( + incident, channel_id="C99999", skip_paging=False + ) + + @patch("firetower.incidents.hooks._page_if_needed") + @patch("firetower.incidents.hooks._slack_service") + def test_passes_skip_paging_to_page_if_needed(self, mock_slack, mock_page): + mock_slack.create_channel.return_value = "C99999" + mock_slack.build_channel_url.return_value = "https://slack.com/archives/C99999" + + incident = Incident.objects.create( + title="Self-handled outage", + severity=IncidentSeverity.P0, + ) + + on_incident_created(incident, skip_paging=True) + + mock_page.assert_called_once_with( + incident, channel_id="C99999", skip_paging=True + ) @patch("firetower.incidents.hooks._page_if_needed") @patch("firetower.incidents.hooks._slack_service") @@ -1074,7 +1126,9 @@ def test_calls_page_regardless_of_severity(self, mock_slack, mock_page): on_incident_created(incident) - mock_page.assert_called_once_with(incident, channel_id="C99999") + mock_page.assert_called_once_with( + incident, channel_id="C99999", skip_paging=False + ) @patch("firetower.incidents.hooks._create_status_channel_for_context") @patch("firetower.incidents.hooks._invite_oncall_to_channel") @@ -1312,6 +1366,22 @@ def test_private_incident_skips_oncall_invite( mock_slack.invite_to_channel.assert_not_called() mock_slack.post_message.assert_not_called() + @patch("firetower.incidents.hooks._slack_service") + @patch("firetower.incidents.hooks.PagerDutyService") + def test_skip_paging_skips_oncall_invite(self, mock_pd_cls, mock_slack, settings): + settings.PAGERDUTY = MOCK_PD_CONFIG + + _invite_oncall_to_channel( + IncidentSeverity.P0, + "C99999", + mock_slack, + skip_paging=True, + ) + + mock_pd_cls.assert_not_called() + mock_slack.invite_to_channel.assert_not_called() + mock_slack.post_message.assert_not_called() + @patch("firetower.incidents.hooks._slack_service") @patch("firetower.incidents.hooks.PagerDutyService") def test_imoc_excludes_oncalls_above_max_level( @@ -1579,6 +1649,7 @@ def test_on_incident_created_calls_invite_oncall( "C99999", mock_slack, is_private=False, + skip_paging=False, paged_policies=set(), ) diff --git a/src/firetower/incidents/tests/test_serializers.py b/src/firetower/incidents/tests/test_serializers.py index 13554f38..163c1360 100644 --- a/src/firetower/incidents/tests/test_serializers.py +++ b/src/firetower/incidents/tests/test_serializers.py @@ -181,7 +181,7 @@ def test_create_calls_on_incident_created(self, mock_hook): ) assert serializer.is_valid(), serializer.errors incident = serializer.save() - mock_hook.assert_called_once_with(incident) + mock_hook.assert_called_once_with(incident, skip_paging=False) @patch("firetower.incidents.serializers.on_incident_updated") def test_update_calls_on_incident_updated_with_status(self, mock_hook): diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index b0c9c699..0287ee12 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -30,6 +30,7 @@ from firetower.slack_app.handlers.new_incident import ( handle_new_command, handle_new_incident_submission, + handle_severity_action, handle_tag_options, ) from firetower.slack_app.handlers.reopen import handle_reopen_command @@ -239,6 +240,7 @@ def _register_views(app: App) -> None: app.view("statuspage_modal")( _with_metrics("statuspage_modal")(handle_statuspage_submission) ) + app.action("severity")(_with_metrics("severity_action")(handle_severity_action)) app.action("component_impact_select")( _with_metrics("component_impact_select")(handle_component_impact_select) ) diff --git a/src/firetower/slack_app/handlers/new_incident.py b/src/firetower/slack_app/handlers/new_incident.py index 0b65bc99..33850c1f 100644 --- a/src/firetower/slack_app/handlers/new_incident.py +++ b/src/firetower/slack_app/handlers/new_incident.py @@ -7,6 +7,7 @@ from firetower.auth.services import get_or_create_user_from_slack_id from firetower.incidents.hooks import ( + HIGH_SEVERITIES, ChannelSetupContext, decorate_incident_channel, page_for_channel, @@ -32,6 +33,7 @@ def _create_fallback_channel(client: Any, slack_user_id: str, form_data: dict) - impact_summary = form_data.get("impact_summary", "") captain_slack_id = form_data.get("captain_slack_id") is_private = form_data.get("is_private", False) + skip_paging = form_data.get("skip_paging", False) channel_name = f"{settings.PROJECT_KEY.lower()}-{uuid.uuid4().hex[:8]}" @@ -90,6 +92,7 @@ def _create_fallback_channel(client: Any, slack_user_id: str, form_data: dict) - title=title, severity=severity, is_private=is_private, + skip_paging=skip_paging, captain_slack_id=captain_slack_id, reporter_slack_id=slack_user_id, description=description, @@ -108,6 +111,7 @@ def _create_fallback_channel(client: Any, slack_user_id: str, form_data: dict) - links=links, channel_id=channel_id, is_private=is_private, + skip_paging=skip_paging, ) except Exception: logger.exception( @@ -129,27 +133,65 @@ def _create_fallback_channel(client: Any, slack_user_id: str, form_data: dict) - logger.exception("Failed to DM user about fallback channel %s", channel_name) -def _build_new_incident_modal(channel_id: str = "", user_id: str = "") -> dict: +_PRIVATE_OPTION: dict[str, Any] = { + "text": {"type": "plain_text", "text": "Private incident"}, + "value": "private", +} + +_SKIP_PAGING_OPTION: dict[str, Any] = { + "text": { + "type": "plain_text", + "text": "Skip paging on-call responders", + }, + "value": "skip_paging", +} + + +def _build_options_block( + severity: str, selected_values: set[str] | None = None +) -> dict[str, Any]: + options = [_PRIVATE_OPTION] + initial_options: list[dict[str, Any]] = [] + + is_high = severity in HIGH_SEVERITIES + if is_high: + options.append(_SKIP_PAGING_OPTION) + + if selected_values is not None: + initial_options.extend(o for o in options if o["value"] in selected_values) + elif is_high: + initial_options.append(_SKIP_PAGING_OPTION) + + element: dict[str, Any] = { + "type": "checkboxes", + "action_id": "incident_options", + "options": options, + } + if initial_options: + element["initial_options"] = initial_options + + return { + "type": "input", + "block_id": "options_block", + "optional": True, + "element": element, + "label": {"type": "plain_text", "text": "Options"}, + } + + +def _build_new_incident_modal( + channel_id: str = "", + user_id: str = "", + severity: str = "", + selected_options: set[str] | None = None, +) -> dict: blocks = build_incident_form_blocks(user_id=user_id) - blocks.append( - { - "type": "input", - "block_id": "private_block", - "optional": True, - "element": { - "type": "checkboxes", - "action_id": "is_private", - "options": [ - { - "text": {"type": "plain_text", "text": "Private incident"}, - "value": "private", - } - ], - }, - "label": {"type": "plain_text", "text": "Visibility"}, - } - ) + for block in blocks: + if block.get("block_id") == "severity_block": + block["dispatch_action"] = True + + blocks.append(_build_options_block(severity, selected_values=selected_options)) modal: dict[str, Any] = { "type": "modal", @@ -210,8 +252,48 @@ def handle_new_command(ack: Any, body: dict, command: dict, respond: Any) -> Non ) +def handle_severity_action(ack: Any, body: dict, client: Any) -> None: + ack() + view = body.get("view", {}) + if view.get("callback_id") != "new_incident_modal": + return + + values = view.get("state", {}).get("values", {}) + selected_option = ( + values.get("severity_block", {}).get("severity", {}).get("selected_option") + ) + severity = selected_option.get("value") if selected_option else "" + channel_id = view.get("private_metadata", "") + user_id = body.get("user", {}).get("id", "") + + prior_selections = ( + values.get("options_block", {}) + .get("incident_options", {}) + .get("selected_options") + or [] + ) + selected_values = {opt.get("value") for opt in prior_selections} + + if severity in HIGH_SEVERITIES and "skip_paging" not in selected_values: + selected_values.add("skip_paging") + + new_view = _build_new_incident_modal( + channel_id=channel_id, + user_id=user_id, + severity=severity, + selected_options=selected_values, + ) + + client.views_update(view_id=view["id"], view=new_view) + + def _create_incident_via_db( - form: dict, slack_user_id: str, is_private: bool, client: Any + form: dict, + slack_user_id: str, + is_private: bool, + client: Any, + *, + skip_paging: bool = False, ) -> "Incident | None": """Run all DB-dependent work to create an incident. @@ -244,7 +326,9 @@ def _create_incident_via_db( "is_private": is_private, } - serializer = IncidentWriteSerializer(data=data) + serializer = IncidentWriteSerializer( + data=data, context={"skip_paging": skip_paging} + ) if not serializer.is_valid(): logger.error(f"Incident validation failed: {serializer.errors}") client.chat_postMessage( @@ -262,11 +346,15 @@ def handle_new_incident_submission( form = parse_incident_form_values(view) values = view.get("state", {}).get("values", {}) - private_selections = ( - values.get("private_block", {}).get("is_private", {}).get("selected_options") + option_selections = ( + values.get("options_block", {}) + .get("incident_options", {}) + .get("selected_options") or [] ) - is_private = any(opt.get("value") == "private" for opt in private_selections) + selected_values = {opt.get("value") for opt in option_selections} + is_private = "private" in selected_values + skip_paging = "skip_paging" in selected_values if not form["title"]: ack( @@ -280,7 +368,9 @@ def handle_new_incident_submission( slack_user_id = body.get("user", {}).get("id", "") try: - incident = _create_incident_via_db(form, slack_user_id, is_private, client) + incident = _create_incident_via_db( + form, slack_user_id, is_private, client, skip_paging=skip_paging + ) except (OperationalError, InterfaceError): logger.exception( "Database unreachable during incident creation from Slack modal" @@ -292,6 +382,7 @@ def handle_new_incident_submission( "impact_summary": form["impact_summary"], "captain_slack_id": form["captain_slack_id"], "is_private": is_private, + "skip_paging": skip_paging, } _create_fallback_channel(client, slack_user_id, form_data) return diff --git a/src/firetower/slack_app/tests/handlers/test_new_incident.py b/src/firetower/slack_app/tests/handlers/test_new_incident.py index 371601e7..978f5e39 100644 --- a/src/firetower/slack_app/tests/handlers/test_new_incident.py +++ b/src/firetower/slack_app/tests/handlers/test_new_incident.py @@ -9,9 +9,11 @@ from firetower.incidents.models import Incident, IncidentSeverity, Tag, TagType from firetower.slack_app.bolt import handle_command from firetower.slack_app.handlers.new_incident import ( + _build_new_incident_modal, _create_fallback_channel, handle_new_command, handle_new_incident_submission, + handle_severity_action, handle_tag_options, ) @@ -75,10 +77,214 @@ def test_new_modal_has_minimal_blocks(self, mock_get_bolt_app): "title_block", "description_block", "impact_summary_block", - "private_block", + "options_block", } +def _get_options_block(modal: dict) -> dict: + return next(b for b in modal["blocks"] if b.get("block_id") == "options_block") + + +def _option_values(modal: dict) -> set[str]: + block = _get_options_block(modal) + return {o["value"] for o in block["element"]["options"]} + + +def _initial_values(modal: dict) -> set[str]: + block = _get_options_block(modal) + return {o["value"] for o in block["element"].get("initial_options", [])} + + +class TestBuildNewIncidentModal: + def test_skip_paging_option_present_for_p0(self): + modal = _build_new_incident_modal(severity="P0") + assert "skip_paging" in _option_values(modal) + assert "skip_paging" in _initial_values(modal) + + def test_skip_paging_option_present_for_p1(self): + modal = _build_new_incident_modal(severity="P1") + assert "skip_paging" in _option_values(modal) + + def test_skip_paging_option_absent_for_p3(self): + modal = _build_new_incident_modal(severity="P3") + assert "skip_paging" not in _option_values(modal) + + def test_skip_paging_option_absent_by_default(self): + modal = _build_new_incident_modal() + assert "skip_paging" not in _option_values(modal) + + def test_private_option_always_present(self): + modal = _build_new_incident_modal() + assert "private" in _option_values(modal) + + def test_selected_options_preserves_private(self): + modal = _build_new_incident_modal( + severity="P0", selected_options={"private", "skip_paging"} + ) + assert _initial_values(modal) == {"private", "skip_paging"} + + def test_selected_options_preserves_private_for_low_severity(self): + modal = _build_new_incident_modal(severity="P3", selected_options={"private"}) + assert _initial_values(modal) == {"private"} + + def test_selected_options_drops_skip_paging_for_low_severity(self): + modal = _build_new_incident_modal( + severity="P3", selected_options={"private", "skip_paging"} + ) + assert "skip_paging" not in _initial_values(modal) + assert "private" in _initial_values(modal) + + def test_selected_options_empty_set_clears_defaults(self): + modal = _build_new_incident_modal(severity="P0", selected_options=set()) + assert _initial_values(modal) == set() + + def test_severity_block_has_dispatch_action(self): + modal = _build_new_incident_modal() + severity_block = next( + b for b in modal["blocks"] if b.get("block_id") == "severity_block" + ) + assert severity_block.get("dispatch_action") is True + + +class TestSeverityAction: + def test_updates_view_with_skip_paging_for_p0(self): + ack = MagicMock() + client = MagicMock() + body = { + "view": { + "id": "V_TEST", + "callback_id": "new_incident_modal", + "private_metadata": "", + "state": { + "values": { + "severity_block": { + "severity": { + "selected_option": {"value": "P0"}, + } + } + } + }, + } + } + + handle_severity_action(ack, body, client) + + ack.assert_called_once() + client.views_update.assert_called_once() + updated_view = client.views_update.call_args[1]["view"] + assert "skip_paging" in _option_values(updated_view) + assert "skip_paging" in _initial_values(updated_view) + + def test_removes_skip_paging_for_p3(self): + ack = MagicMock() + client = MagicMock() + body = { + "view": { + "id": "V_TEST", + "callback_id": "new_incident_modal", + "private_metadata": "", + "state": { + "values": { + "severity_block": { + "severity": { + "selected_option": {"value": "P3"}, + } + } + } + }, + } + } + + handle_severity_action(ack, body, client) + + ack.assert_called_once() + updated_view = client.views_update.call_args[1]["view"] + assert "skip_paging" not in _option_values(updated_view) + + def test_preserves_private_selection_on_severity_change(self): + ack = MagicMock() + client = MagicMock() + body = { + "view": { + "id": "V_TEST", + "callback_id": "new_incident_modal", + "private_metadata": "", + "state": { + "values": { + "severity_block": { + "severity": { + "selected_option": {"value": "P1"}, + } + }, + "options_block": { + "incident_options": { + "selected_options": [{"value": "private"}] + } + }, + } + }, + } + } + + handle_severity_action(ack, body, client) + + updated_view = client.views_update.call_args[1]["view"] + assert "private" in _initial_values(updated_view) + + def test_preserves_captain_initial_user_on_severity_change(self): + ack = MagicMock() + client = MagicMock() + body = { + "user": {"id": "U_OPENER"}, + "view": { + "id": "V_TEST", + "callback_id": "new_incident_modal", + "private_metadata": "", + "state": { + "values": { + "severity_block": { + "severity": { + "selected_option": {"value": "P0"}, + } + }, + } + }, + }, + } + + handle_severity_action(ack, body, client) + + updated_view = client.views_update.call_args[1]["view"] + captain_block = next( + b for b in updated_view["blocks"] if b.get("block_id") == "captain_block" + ) + assert captain_block["element"]["initial_user"] == "U_OPENER" + + def test_ignores_non_new_incident_modal(self): + ack = MagicMock() + client = MagicMock() + body = { + "view": { + "id": "V_TEST", + "callback_id": "other_modal", + "state": { + "values": { + "severity_block": { + "severity": { + "selected_option": {"value": "P0"}, + } + } + } + }, + } + } + + handle_severity_action(ack, body, client) + + ack.assert_called_once() + client.views_update.assert_not_called() + + @pytest.mark.django_db class TestNewIncidentSubmission: def setup_method(self): @@ -112,7 +318,7 @@ def test_creates_incident(self, mock_get_user, mock_hook): } }, "description_block": {"description": {"value": "Description"}}, - "private_block": {"is_private": {"selected_options": []}}, + "options_block": {"incident_options": {"selected_options": []}}, } } } @@ -146,7 +352,7 @@ def test_posts_to_invoking_channel(self, mock_get_user, mock_hook, mock_slack_sv } }, "description_block": {"description": {"value": "Description"}}, - "private_block": {"is_private": {"selected_options": []}}, + "options_block": {"incident_options": {"selected_options": []}}, } }, } @@ -180,8 +386,8 @@ def test_private_incident_skips_invoking_channel( } }, "description_block": {"description": {"value": "Description"}}, - "private_block": { - "is_private": {"selected_options": [{"value": "private"}]} + "options_block": { + "incident_options": {"selected_options": [{"value": "private"}]} }, } }, @@ -192,6 +398,70 @@ def test_private_incident_skips_invoking_channel( client.chat_postMessage.assert_called_once() assert client.chat_postMessage.call_args[1]["channel"] == "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_skip_paging_passed_to_hook(self, mock_get_user, mock_hook, settings): + settings.HOOKS_ENABLED = True + mock_get_user.return_value = self.user + + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_TEST"}} + view = { + "state": { + "values": { + "title_block": {"title": {"value": "Self-handled P1"}}, + "severity_block": { + "severity": { + "selected_option": {"value": "P1"}, + } + }, + "description_block": {"description": {"value": ""}}, + "options_block": { + "incident_options": { + "selected_options": [{"value": "skip_paging"}] + } + }, + } + } + } + + handle_new_incident_submission(ack, body, view, client) + + mock_hook.assert_called_once() + incident = mock_hook.call_args[0][0] + assert incident.title == "Self-handled P1" + assert mock_hook.call_args[1]["skip_paging"] is True + + @patch("firetower.incidents.serializers.on_incident_created") + @patch("firetower.slack_app.handlers.new_incident.get_or_create_user_from_slack_id") + def test_skip_paging_default_false(self, mock_get_user, mock_hook, settings): + settings.HOOKS_ENABLED = True + mock_get_user.return_value = self.user + + ack = MagicMock() + client = MagicMock() + body = {"user": {"id": "U_TEST"}} + view = { + "state": { + "values": { + "title_block": {"title": {"value": "Normal P1"}}, + "severity_block": { + "severity": { + "selected_option": {"value": "P1"}, + } + }, + "description_block": {"description": {"value": ""}}, + "options_block": {"incident_options": {"selected_options": []}}, + } + } + } + + handle_new_incident_submission(ack, body, view, client) + + mock_hook.assert_called_once() + assert mock_hook.call_args[1]["skip_paging"] is False + def test_empty_title_returns_modal_error(self): ack = MagicMock() client = MagicMock() @@ -206,7 +476,7 @@ def test_empty_title_returns_modal_error(self): } }, "description_block": {"description": {"value": ""}}, - "private_block": {"is_private": {"selected_options": []}}, + "options_block": {"incident_options": {"selected_options": []}}, } } } @@ -233,7 +503,7 @@ def test_unknown_user_sends_dm(self, mock_get_user): "severity": {"selected_option": {"value": "P1"}} }, "description_block": {"description": {"value": ""}}, - "private_block": {"is_private": {"selected_options": []}}, + "options_block": {"incident_options": {"selected_options": []}}, } } } @@ -278,7 +548,7 @@ def test_save_failure_creates_fallback_channel( "impact_summary": {"value": "Users affected"} }, "captain_block": {"captain_select": {"selected_user": "U_CAP"}}, - "private_block": {"is_private": {"selected_options": []}}, + "options_block": {"incident_options": {"selected_options": []}}, "impact_type_block": {"impact_type_tags": {"selected_options": []}}, "affected_service_block": { "affected_service_tags": {"selected_options": []} @@ -328,7 +598,7 @@ def test_db_down_before_save_creates_fallback_channel( }, "impact_summary_block": {"impact_summary": {"value": "All users"}}, "captain_block": {"captain_select": {"selected_user": None}}, - "private_block": {"is_private": {"selected_options": []}}, + "options_block": {"incident_options": {"selected_options": []}}, "impact_type_block": {"impact_type_tags": {"selected_options": []}}, "affected_service_block": { "affected_service_tags": {"selected_options": []} @@ -372,7 +642,7 @@ def test_non_db_save_failure_sends_dm_without_fallback( } }, "description_block": {"description": {"value": "Description"}}, - "private_block": {"is_private": {"selected_options": []}}, + "options_block": {"incident_options": {"selected_options": []}}, } } } @@ -630,3 +900,22 @@ def test_non_private_posts_to_feed_channel(self, mock_slack_svc, settings): assert len(feed_calls) == 1 assert "degraded mode" in feed_calls[0][0][1] assert "DB is on fire" in feed_calls[0][0][1] + + @patch("firetower.slack_app.handlers.new_incident.page_for_channel") + @patch("firetower.slack_app.handlers.new_incident._slack_service") + def test_skip_paging_skips_page_for_channel(self, mock_slack_svc, mock_page): + mock_slack_svc.create_channel.return_value = "C_FALLBACK" + mock_slack_svc.post_message.return_value = "1234.5678" + mock_slack_svc.pin_message.return_value = True + mock_slack_svc.build_channel_url.return_value = ( + "https://sentry.slack.com/archives/C_FALLBACK" + ) + client = MagicMock() + + form_data = self._base_form_data() + form_data["skip_paging"] = True + + _create_fallback_channel(client, "U_REPORTER", form_data) + + mock_page.assert_called_once() + assert mock_page.call_args[1]["skip_paging"] is True