From 99a49a4093f85c50324fca7167de7aac3f716eb5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:39:17 +0000 Subject: [PATCH 1/9] feat(incidents): Collect postmortem meeting attendees by reaction Co-Authored-By: Richard Gibert --- config.example.toml | 5 + src/firetower/config.py | 6 + src/firetower/incidents/hooks.py | 61 +++++++++ src/firetower/incidents/tasks/__init__.py | 2 + src/firetower/incidents/tasks/postmortem.py | 47 +++++++ src/firetower/incidents/tests/test_hooks.py | 120 ++++++++++++++++++ src/firetower/incidents/tests/test_tasks.py | 76 +++++++++++ src/firetower/integrations/services/slack.py | 3 + .../integrations/tests/test_slack.py | 7 + src/firetower/settings.py | 6 + 10 files changed, 333 insertions(+) create mode 100644 src/firetower/incidents/tasks/postmortem.py diff --git a/config.example.toml b/config.example.toml index f1575ef9..d213f553 100644 --- a/config.example.toml +++ b/config.example.toml @@ -64,6 +64,11 @@ incident_guide_message = "This is the message posted whenever a new incident sla slash_command = "/inc" triage_bot_user_id = "" triage_bot_prompt = "We've been alerted by {alert_url}, run incident triage for this issue." +# Set to collect postmortem meeting attendees by reaction when a P0/P1 is resolved; omit to disable. +# After the window, Firetower posts the scheduler bot prompt (mentioning the bot when its user id is set). +# postmortem_attendee_collection_minutes = 10 +postmortem_scheduler_bot_user_id = "" +postmortem_scheduler_bot_prompt = "Schedule the {incident_number} postmortem meeting with everyone who reacted to {prompt_url}" [auth] iap_enabled = false diff --git a/src/firetower/config.py b/src/firetower/config.py index 38ff8342..e12ac964 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -75,6 +75,12 @@ class SlackConfig: triage_bot_prompt: str = ( "We've been alerted by {alert_url}, run incident triage for this issue." ) + postmortem_attendee_collection_minutes: int | None = None + postmortem_scheduler_bot_user_id: str = "" + postmortem_scheduler_bot_prompt: str = ( + "Schedule the {incident_number} postmortem meeting with everyone who " + "reacted to {prompt_url}" + ) @deserialize diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 3600e56e..6b73d559 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -1,3 +1,4 @@ +import json import logging from collections import defaultdict from dataclasses import dataclass @@ -64,6 +65,18 @@ def _get_linear_service() -> LinearService: DEFAULT_STATUSPAGE_WARNING_BUFFER_MINUTES = 0 +POSTMORTEM_ATTENDEE_PROMPT_MESSAGE = ( + ":calendar: *Postmortem meeting*\n" + "React to this message with any emoji to be invited to the postmortem " + "meeting for *{incident_number}*. Collecting attendees for the next " + "*{minutes} minutes*." +) + + +def get_postmortem_attendee_collection_minutes() -> int | None: + raw = settings.SLACK.get("POSTMORTEM_ATTENDEE_COLLECTION_MINUTES") + return int(raw) if raw is not None else None + def get_statuspage_initial_reminder_delay_minutes() -> int | None: statuspage = getattr(settings, "STATUSPAGE", None) @@ -1387,6 +1400,42 @@ def _schedule_statuspage_reminder( Schedule.objects.get_or_create(name=schedule_name, defaults=defaults) +def start_postmortem_attendee_collection(incident: Incident, channel_id: str) -> None: + """Post a reaction prompt and schedule the follow-up that hands reactors off for scheduling.""" + if incident.severity not in HIGH_SEVERITIES: + return + + minutes = get_postmortem_attendee_collection_minutes() + if minutes is None: + return + + message_ts = _slack_service.post_message( + channel_id, + POSTMORTEM_ATTENDEE_PROMPT_MESSAGE.format( + incident_number=incident.incident_number, minutes=minutes + ), + ) + if not message_ts: + return + + Schedule.objects.update_or_create( + name=f"postmortem_attendee_collection_{incident.id}", + defaults={ + "func": "firetower.incidents.tasks.send_postmortem_attendee_followup", + "kwargs": json.dumps( + { + "incident_id": incident.id, + "channel_id": channel_id, + "message_ts": message_ts, + } + ), + "schedule_type": Schedule.ONCE, + "next_run": timezone.now() + timedelta(minutes=minutes), + "repeats": -1, + }, + ) + + def cancel_statuspage_followup_reminder(incident: Incident) -> None: Schedule.objects.filter(name=f"statuspage_followup_reminder_{incident.id}").delete() @@ -1873,6 +1922,18 @@ def on_incident_updated( f"Failed to trigger slack dump in on_incident_updated for incident {incident.id}" ) + if ( + old_status is not None + and incident.status == IncidentStatus.POSTMORTEM + and channel_id + ): + try: + start_postmortem_attendee_collection(incident, channel_id) + except Exception: + logger.exception( + f"Failed to start postmortem attendee collection for incident {incident.id}" + ) + # Status change into a mitigated/terminal state: resolve any PD pages we sent. # Best-effort so a PagerDuty hiccup never breaks the incident update. # resolve_pages_for_incident is intentionally severity- and visibility- diff --git a/src/firetower/incidents/tasks/__init__.py b/src/firetower/incidents/tasks/__init__.py index 3e562280..497d7460 100644 --- a/src/firetower/incidents/tasks/__init__.py +++ b/src/firetower/incidents/tasks/__init__.py @@ -5,6 +5,7 @@ from firetower.incidents.models import Incident from firetower.incidents.tasks.action_items import send_action_item_reminder from firetower.incidents.tasks.decorators import datadog_log +from firetower.incidents.tasks.postmortem import send_postmortem_attendee_followup from firetower.incidents.tasks.statuspage import ( STATUSPAGE_FOLLOWUP_REMINDER_MESSAGE, STATUSPAGE_REMINDER_MESSAGE, @@ -19,6 +20,7 @@ "datadog_log", "schedule_demo", "send_action_item_reminder", + "send_postmortem_attendee_followup", "send_statuspage_followup_reminder", "send_statuspage_reminder", ] diff --git a/src/firetower/incidents/tasks/postmortem.py b/src/firetower/incidents/tasks/postmortem.py new file mode 100644 index 00000000..55a755e4 --- /dev/null +++ b/src/firetower/incidents/tasks/postmortem.py @@ -0,0 +1,47 @@ +import logging +from collections import defaultdict + +from django.conf import settings + +from firetower.incidents.models import Incident, IncidentStatus +from firetower.incidents.tasks.decorators import datadog_log +from firetower.integrations.services.slack import SlackService + +logger = logging.getLogger(__name__) + + +@datadog_log +def send_postmortem_attendee_followup( + incident_id: int, channel_id: str, message_ts: str +) -> None: + """Close the reaction window by posting the scheduler bot prompt for the reactors.""" + prompt = settings.SLACK.get("POSTMORTEM_SCHEDULER_BOT_PROMPT", "") + if not prompt: + return + + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + logger.warning( + f"Incident {incident_id} not found for postmortem attendee followup" + ) + return + + if incident.status not in (IncidentStatus.POSTMORTEM, IncidentStatus.DONE): + return + + slack = SlackService() + text = prompt.format_map( + defaultdict( + str, + incident_number=incident.incident_number, + prompt_url=slack.build_message_url(channel_id, message_ts), + ) + ) + + bot_user_id = settings.SLACK.get("POSTMORTEM_SCHEDULER_BOT_USER_ID", "") + if bot_user_id: + slack.invite_to_channel(channel_id, [bot_user_id]) + text = f"<@{bot_user_id}> {text}" + + slack.post_message(channel_id, text) diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index 8e4dd7af..aa91e6a5 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -32,6 +32,7 @@ page_for_channel, resolve_pages_for_incident, schedule_statuspage_followup_reminder, + start_postmortem_attendee_collection, ) from firetower.incidents.models import ( ExternalLink, @@ -4142,3 +4143,122 @@ def test_does_not_sync_linear_assignee_without_captain_change( on_incident_updated(incident, old_status=IncidentStatus.ACTIVE) mock_get_linear.return_value.update_issue.assert_not_called() + + +@pytest.mark.django_db +class TestPostmortemAttendeeCollection: + @pytest.fixture(autouse=True) + def _configure_collection(self, settings): + settings.SLACK = { + **settings.SLACK, + "POSTMORTEM_ATTENDEE_COLLECTION_MINUTES": 10, + } + + def _make_incident(self, **kwargs): + defaults = { + "title": "Test Incident", + "severity": IncidentSeverity.P1, + "status": IncidentStatus.POSTMORTEM, + } + defaults.update(kwargs) + return Incident.objects.create(**defaults) + + @patch("firetower.incidents.hooks._slack_service") + def test_posts_prompt_and_schedules_collection(self, mock_slack): + mock_slack.post_message.return_value = "1111.2222" + incident = self._make_incident() + + before = timezone.now() + start_postmortem_attendee_collection(incident, "C12345") + + prompt = mock_slack.post_message.call_args[0][1] + assert incident.incident_number in prompt + assert "10 minutes" in prompt + + schedule = Schedule.objects.get( + name=f"postmortem_attendee_collection_{incident.id}" + ) + assert ( + schedule.func + == "firetower.incidents.tasks.send_postmortem_attendee_followup" + ) + assert schedule.schedule_type == Schedule.ONCE + assert ast.literal_eval(schedule.kwargs) == { + "incident_id": incident.id, + "channel_id": "C12345", + "message_ts": "1111.2222", + } + assert schedule.next_run >= before + timedelta(minutes=10) + + @patch("firetower.incidents.hooks._slack_service") + def test_skips_low_severity(self, mock_slack): + incident = self._make_incident(severity=IncidentSeverity.P2) + + start_postmortem_attendee_collection(incident, "C12345") + + mock_slack.post_message.assert_not_called() + assert not Schedule.objects.filter( + name__startswith="postmortem_attendee_collection_" + ).exists() + + @patch("firetower.incidents.hooks._slack_service") + def test_skips_when_not_configured(self, mock_slack, settings): + settings.SLACK = { + **settings.SLACK, + "POSTMORTEM_ATTENDEE_COLLECTION_MINUTES": None, + } + incident = self._make_incident() + + start_postmortem_attendee_collection(incident, "C12345") + + mock_slack.post_message.assert_not_called() + assert not Schedule.objects.filter( + name__startswith="postmortem_attendee_collection_" + ).exists() + + @patch("firetower.incidents.hooks._slack_service") + def test_skips_schedule_when_prompt_fails(self, mock_slack): + mock_slack.post_message.return_value = None + incident = self._make_incident() + + start_postmortem_attendee_collection(incident, "C12345") + + assert not Schedule.objects.filter( + name__startswith="postmortem_attendee_collection_" + ).exists() + + @patch("firetower.incidents.hooks.start_postmortem_attendee_collection") + @patch("firetower.slack_app.handlers.dumpslack.trigger_slack_dump_async") + @patch("firetower.incidents.hooks._slack_service") + def test_on_incident_updated_starts_collection_on_resolve( + self, mock_slack, _mock_dump, mock_start + ): + mock_slack.parse_channel_id_from_url.return_value = "C12345" + incident = self._make_incident() + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C12345", + ) + + on_incident_updated(incident, old_status=IncidentStatus.MITIGATED) + + mock_start.assert_called_once_with(incident, "C12345") + + @patch("firetower.incidents.hooks.start_postmortem_attendee_collection") + @patch("firetower.slack_app.handlers.dumpslack.trigger_slack_dump_async") + @patch("firetower.incidents.hooks._slack_service") + def test_on_incident_updated_ignores_other_transitions( + self, mock_slack, _mock_dump, mock_start + ): + mock_slack.parse_channel_id_from_url.return_value = "C12345" + incident = self._make_incident(status=IncidentStatus.DONE) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url="https://slack.com/archives/C12345", + ) + + on_incident_updated(incident, old_status=IncidentStatus.POSTMORTEM) + + mock_start.assert_not_called() diff --git a/src/firetower/incidents/tests/test_tasks.py b/src/firetower/incidents/tests/test_tasks.py index f9f53ed2..8a03c6a9 100644 --- a/src/firetower/incidents/tests/test_tasks.py +++ b/src/firetower/incidents/tests/test_tasks.py @@ -23,6 +23,7 @@ datadog_log, schedule_demo, send_action_item_reminder, + send_postmortem_attendee_followup, send_statuspage_followup_reminder, send_statuspage_reminder, ) @@ -1236,3 +1237,78 @@ def test_slack_failure_does_not_block_last_nag(self, mock_linear, mock_slack): action_item.refresh_from_db() assert action_item.last_nag is not None + + +@pytest.mark.django_db +class TestSendPostmortemAttendeeFollowup: + @pytest.fixture(autouse=True) + def _configure_scheduler_bot(self): + slack_settings = { + **settings.SLACK, + "POSTMORTEM_SCHEDULER_BOT_USER_ID": "U_SCHEDULER", + "POSTMORTEM_SCHEDULER_BOT_PROMPT": ( + "Schedule {incident_number} with reactors on {prompt_url}" + ), + } + with patch.object(settings, "SLACK", slack_settings): + yield + + def _make_incident(self, **kwargs): + defaults = { + "title": "Test Incident", + "status": IncidentStatus.POSTMORTEM, + "severity": IncidentSeverity.P1, + } + defaults.update(kwargs) + return Incident.objects.create(**defaults) + + def _run(self, incident): + mock_slack = MagicMock() + mock_slack.build_message_url.return_value = ( + "https://sentry.slack.com/archives/C12345/p11112222" + ) + with patch( + "firetower.incidents.tasks.postmortem.SlackService", + return_value=mock_slack, + ): + send_postmortem_attendee_followup(incident.id, "C12345", "1111.2222") + return mock_slack + + def test_invites_and_mentions_scheduler_bot(self): + incident = self._make_incident() + + mock_slack = self._run(incident) + + mock_slack.build_message_url.assert_called_once_with("C12345", "1111.2222") + mock_slack.invite_to_channel.assert_called_once_with("C12345", ["U_SCHEDULER"]) + mock_slack.post_message.assert_called_once_with( + "C12345", + f"<@U_SCHEDULER> Schedule {incident.incident_number} with reactors on " + "https://sentry.slack.com/archives/C12345/p11112222", + ) + + def test_posts_prompt_without_mention_when_no_bot(self): + incident = self._make_incident() + settings.SLACK["POSTMORTEM_SCHEDULER_BOT_USER_ID"] = "" + + mock_slack = self._run(incident) + + mock_slack.invite_to_channel.assert_not_called() + message = mock_slack.post_message.call_args[0][1] + assert message.startswith(f"Schedule {incident.incident_number}") + + def test_skips_when_prompt_empty(self): + incident = self._make_incident() + settings.SLACK["POSTMORTEM_SCHEDULER_BOT_PROMPT"] = "" + + mock_slack = self._run(incident) + + mock_slack.post_message.assert_not_called() + + @pytest.mark.parametrize("status", [IncidentStatus.ACTIVE, IncidentStatus.CANCELED]) + def test_skips_when_incident_left_postmortem(self, status): + incident = self._make_incident(status=status) + + mock_slack = self._run(incident) + + mock_slack.post_message.assert_not_called() diff --git a/src/firetower/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index eebb0d7a..84ca69f0 100644 --- a/src/firetower/integrations/services/slack.py +++ b/src/firetower/integrations/services/slack.py @@ -466,6 +466,9 @@ def add_bookmark(self, channel_id: str, title: str, link: str) -> bool: def build_channel_url(self, channel_id: str) -> str: return f"https://{self.team_id}.slack.com/archives/{channel_id}" + def build_message_url(self, channel_id: str, message_ts: str) -> str: + return f"{self.build_channel_url(channel_id)}/p{message_ts.replace('.', '')}" + def get_channel_info(self, channel_id: str) -> dict | None: if not self.client: logger.warning("Cannot fetch channel info - Slack client not initialized") diff --git a/src/firetower/integrations/tests/test_slack.py b/src/firetower/integrations/tests/test_slack.py index fdacbeea..4e07b5e2 100644 --- a/src/firetower/integrations/tests/test_slack.py +++ b/src/firetower/integrations/tests/test_slack.py @@ -551,6 +551,13 @@ def test_build_channel_url(self): assert url == "https://sentry.slack.com/archives/C12345" assert service.parse_channel_id_from_url(url) == "C12345" + def test_build_message_url(self): + service, _ = self._make_service() + assert ( + service.build_message_url("C12345", "1111.2222") + == "https://sentry.slack.com/archives/C12345/p11112222" + ) + def test_get_channel_info_unexpected_error_returns_none(self): service, mock_client = self._make_service() mock_client.conversations_info.side_effect = TimeoutError diff --git a/src/firetower/settings.py b/src/firetower/settings.py index 7315d51b..ede73a51 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -262,6 +262,9 @@ class SlackSettings(TypedDict): SLASH_COMMAND: str TRIAGE_BOT_USER_ID: str TRIAGE_BOT_PROMPT: str + POSTMORTEM_ATTENDEE_COLLECTION_MINUTES: int | None + POSTMORTEM_SCHEDULER_BOT_USER_ID: str + POSTMORTEM_SCHEDULER_BOT_PROMPT: str SLACK: SlackSettings = { @@ -274,6 +277,9 @@ class SlackSettings(TypedDict): "SLASH_COMMAND": config.slack.slash_command, "TRIAGE_BOT_USER_ID": config.slack.triage_bot_user_id, "TRIAGE_BOT_PROMPT": config.slack.triage_bot_prompt, + "POSTMORTEM_ATTENDEE_COLLECTION_MINUTES": config.slack.postmortem_attendee_collection_minutes, + "POSTMORTEM_SCHEDULER_BOT_USER_ID": config.slack.postmortem_scheduler_bot_user_id, + "POSTMORTEM_SCHEDULER_BOT_PROMPT": config.slack.postmortem_scheduler_bot_prompt, } PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds) From edb2d2fd94aa419fdc3bfa35af19a412c82de8fb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:42:08 +0000 Subject: [PATCH 2/9] feat(incidents): Fall back to triage bot for postmortem scheduling Co-Authored-By: Richard Gibert --- config.example.toml | 3 ++- src/firetower/incidents/tasks/postmortem.py | 4 +++- src/firetower/incidents/tests/test_tasks.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/config.example.toml b/config.example.toml index d213f553..a86230c8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -65,7 +65,8 @@ slash_command = "/inc" triage_bot_user_id = "" triage_bot_prompt = "We've been alerted by {alert_url}, run incident triage for this issue." # Set to collect postmortem meeting attendees by reaction when a P0/P1 is resolved; omit to disable. -# After the window, Firetower posts the scheduler bot prompt (mentioning the bot when its user id is set). +# After the window, Firetower posts the scheduler bot prompt, mentioning postmortem_scheduler_bot_user_id +# (or triage_bot_user_id when unset). # postmortem_attendee_collection_minutes = 10 postmortem_scheduler_bot_user_id = "" postmortem_scheduler_bot_prompt = "Schedule the {incident_number} postmortem meeting with everyone who reacted to {prompt_url}" diff --git a/src/firetower/incidents/tasks/postmortem.py b/src/firetower/incidents/tasks/postmortem.py index 55a755e4..64f0b40b 100644 --- a/src/firetower/incidents/tasks/postmortem.py +++ b/src/firetower/incidents/tasks/postmortem.py @@ -39,7 +39,9 @@ def send_postmortem_attendee_followup( ) ) - bot_user_id = settings.SLACK.get("POSTMORTEM_SCHEDULER_BOT_USER_ID", "") + bot_user_id = settings.SLACK.get( + "POSTMORTEM_SCHEDULER_BOT_USER_ID" + ) or settings.SLACK.get("TRIAGE_BOT_USER_ID", "") if bot_user_id: slack.invite_to_channel(channel_id, [bot_user_id]) text = f"<@{bot_user_id}> {text}" diff --git a/src/firetower/incidents/tests/test_tasks.py b/src/firetower/incidents/tests/test_tasks.py index 8a03c6a9..27f31f8b 100644 --- a/src/firetower/incidents/tests/test_tasks.py +++ b/src/firetower/incidents/tests/test_tasks.py @@ -1287,9 +1287,21 @@ def test_invites_and_mentions_scheduler_bot(self): "https://sentry.slack.com/archives/C12345/p11112222", ) + def test_falls_back_to_triage_bot(self): + incident = self._make_incident() + settings.SLACK["POSTMORTEM_SCHEDULER_BOT_USER_ID"] = "" + settings.SLACK["TRIAGE_BOT_USER_ID"] = "U_TRIAGE" + + mock_slack = self._run(incident) + + mock_slack.invite_to_channel.assert_called_once_with("C12345", ["U_TRIAGE"]) + message = mock_slack.post_message.call_args[0][1] + assert message.startswith("<@U_TRIAGE> ") + def test_posts_prompt_without_mention_when_no_bot(self): incident = self._make_incident() settings.SLACK["POSTMORTEM_SCHEDULER_BOT_USER_ID"] = "" + settings.SLACK["TRIAGE_BOT_USER_ID"] = "" mock_slack = self._run(incident) From 4efc017acd90df9a45f193c4fb8535a10b72185f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:57:22 +0000 Subject: [PATCH 3/9] feat(incidents): Record postmortem meeting link from scheduler bot Co-Authored-By: Richard Gibert --- config.example.toml | 4 +- .../$incidentId/components/LinksList.tsx | 4 ++ .../queries/incidentDetailQueryOptions.ts | 1 + .../queries/incidentTimelineQueryOptions.ts | 1 + src/firetower/config.py | 2 +- src/firetower/incidents/hooks.py | 23 +++++++ .../migrations/0026_postmortem_meeting.py | 52 +++++++++++++++ src/firetower/incidents/models.py | 5 ++ src/firetower/incidents/tasks/postmortem.py | 5 +- src/firetower/incidents/tests/test_hooks.py | 63 ++++++++++++++++++ src/firetower/incidents/tests/test_models.py | 2 +- .../incidents/tests/test_timeline.py | 5 ++ src/firetower/incidents/timeline/events.py | 13 ++++ src/firetower/incidents/timeline/render.py | 2 + src/firetower/slack_app/bolt.py | 13 +++- .../slack_app/handlers/postmortem_meeting.py | 43 ++++++++++++ .../tests/handlers/test_postmortem_meeting.py | 66 +++++++++++++++++++ 17 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 src/firetower/incidents/migrations/0026_postmortem_meeting.py create mode 100644 src/firetower/slack_app/handlers/postmortem_meeting.py create mode 100644 src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py diff --git a/config.example.toml b/config.example.toml index a86230c8..9ba828d3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -69,7 +69,9 @@ triage_bot_prompt = "We've been alerted by {alert_url}, run incident triage for # (or triage_bot_user_id when unset). # postmortem_attendee_collection_minutes = 10 postmortem_scheduler_bot_user_id = "" -postmortem_scheduler_bot_prompt = "Schedule the {incident_number} postmortem meeting with everyone who reacted to {prompt_url}" +# When the scheduler bot replies in the incident channel with a Google Meet link, Firetower saves it +# as the incident's postmortem meeting link and adds it to the timeline. +postmortem_scheduler_bot_prompt = "Schedule the {incident_number} postmortem meeting with everyone who reacted to {prompt_url}, then reply with the Google Meet link." [auth] iap_enabled = false diff --git a/frontend/src/routes/$incidentId/components/LinksList.tsx b/frontend/src/routes/$incidentId/components/LinksList.tsx index ee98f4f1..7bbf3576 100644 --- a/frontend/src/routes/$incidentId/components/LinksList.tsx +++ b/frontend/src/routes/$incidentId/components/LinksList.tsx @@ -33,6 +33,10 @@ const linkConfigs = { label: 'Meeting Recording', icon: '/meeting_recording.svg', }, + postmortem_meeting: { + label: 'Postmortem meeting', + icon: '/meeting_recording.svg', + }, statuspage: { label: 'Statuspage', icon: '/statuspage.svg', diff --git a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts index 11be3985..acd2f931 100644 --- a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts +++ b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts @@ -21,6 +21,7 @@ const ExternalLinksSchema = z.object({ notion_troubleshooting: z.string().optional(), linear: z.string().optional(), meeting_recording: z.string().optional(), + postmortem_meeting: z.string().optional(), }); const IncidentDetailSchema = z.object({ diff --git a/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts index 635222f4..97fb2fc3 100644 --- a/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts +++ b/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts @@ -15,6 +15,7 @@ const TimelineEventTypeSchema = z.enum([ 'statuspage_incident_created', 'statuspage_update_posted', 'pagerduty_incident_triggered', + 'postmortem_meeting_scheduled', ]); const TimelineActorSchema = z.object({ diff --git a/src/firetower/config.py b/src/firetower/config.py index e12ac964..c4a7e00b 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -79,7 +79,7 @@ class SlackConfig: postmortem_scheduler_bot_user_id: str = "" postmortem_scheduler_bot_prompt: str = ( "Schedule the {incident_number} postmortem meeting with everyone who " - "reacted to {prompt_url}" + "reacted to {prompt_url}, then reply with the Google Meet link." ) diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 6b73d559..516d5cde 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -24,6 +24,7 @@ get_linear_parent_issue_state_id, sync_linear_parent_issue_status, ) +from firetower.incidents.timeline.events import record_postmortem_meeting_scheduled from firetower.integrations.services import ( DatadogService, LinearService, @@ -78,6 +79,12 @@ def get_postmortem_attendee_collection_minutes() -> int | None: return int(raw) if raw is not None else None +def get_postmortem_scheduler_bot_user_id() -> str: + return settings.SLACK.get("POSTMORTEM_SCHEDULER_BOT_USER_ID") or settings.SLACK.get( + "TRIAGE_BOT_USER_ID", "" + ) + + def get_statuspage_initial_reminder_delay_minutes() -> int | None: statuspage = getattr(settings, "STATUSPAGE", None) raw = statuspage.get("INITIAL_REMINDER_DELAY_MINUTES") if statuspage else None @@ -1436,6 +1443,22 @@ def start_postmortem_attendee_collection(incident: Incident, channel_id: str) -> ) +def record_postmortem_meeting(incident: Incident, meeting_url: str) -> None: + """Save the postmortem meeting link on the incident and add it to the timeline.""" + with transaction.atomic(): + link, created = ExternalLink.objects.get_or_create( + incident=incident, + type=ExternalLinkType.POSTMORTEM_MEETING, + defaults={"url": meeting_url}, + ) + if not created: + if link.url == meeting_url: + return + link.url = meeting_url + link.save(update_fields=["url"]) + record_postmortem_meeting_scheduled(incident, meeting_url=meeting_url) + + def cancel_statuspage_followup_reminder(incident: Incident) -> None: Schedule.objects.filter(name=f"statuspage_followup_reminder_{incident.id}").delete() diff --git a/src/firetower/incidents/migrations/0026_postmortem_meeting.py b/src/firetower/incidents/migrations/0026_postmortem_meeting.py new file mode 100644 index 00000000..e6760946 --- /dev/null +++ b/src/firetower/incidents/migrations/0026_postmortem_meeting.py @@ -0,0 +1,52 @@ +# Generated by Django 6.1.2 on 2026-10-28 15:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "0025_alter_actionitem_created_at"), + ] + + operations = [ + migrations.AlterField( + model_name="externallink", + name="type", + field=models.CharField( + choices=[ + ("SLACK", "Slack"), + ("SLACK_STATUS", "Slack Status"), + ("JIRA", "Jira"), + ("DATADOG", "Datadog"), + ("PAGERDUTY", "PagerDuty"), + ("STATUSPAGE", "Statuspage"), + ("NOTION", "Notion"), + ("NOTION_TROUBLESHOOTING", "Notion Troubleshooting"), + ("LINEAR", "Linear"), + ("MEETING_RECORDING", "Meeting Recording"), + ("POSTMORTEM_MEETING", "Postmortem Meeting"), + ], + max_length=30, + ), + ), + migrations.AlterField( + model_name="timelineevent", + name="event_type", + field=models.CharField( + choices=[ + ("note", "Note"), + ("incident_created", "Incident created"), + ("status_changed", "Status changed"), + ("severity_changed", "Severity changed"), + ("captain_changed", "Captain changed"), + ("title_changed", "Title changed"), + ("visibility_changed", "Visibility changed"), + ("statuspage_incident_created", "Statuspage incident created"), + ("statuspage_update_posted", "Statuspage update posted"), + ("pagerduty_incident_triggered", "PagerDuty incident triggered"), + ("postmortem_meeting_scheduled", "Postmortem meeting scheduled"), + ], + max_length=64, + ), + ), + ] diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index 4df575da..cecb922f 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -87,6 +87,10 @@ class TimelineEventType(models.TextChoices): "pagerduty_incident_triggered", "PagerDuty incident triggered", ) + POSTMORTEM_MEETING_SCHEDULED = ( + "postmortem_meeting_scheduled", + "Postmortem meeting scheduled", + ) class ServiceTier(models.TextChoices): @@ -122,6 +126,7 @@ class ExternalLinkType(models.TextChoices): NOTION_TROUBLESHOOTING = "NOTION_TROUBLESHOOTING", "Notion Troubleshooting" LINEAR = "LINEAR", "Linear" MEETING_RECORDING = "MEETING_RECORDING", "Meeting Recording" + POSTMORTEM_MEETING = "POSTMORTEM_MEETING", "Postmortem Meeting" class Tag(models.Model): diff --git a/src/firetower/incidents/tasks/postmortem.py b/src/firetower/incidents/tasks/postmortem.py index 64f0b40b..45fd8d55 100644 --- a/src/firetower/incidents/tasks/postmortem.py +++ b/src/firetower/incidents/tasks/postmortem.py @@ -3,6 +3,7 @@ from django.conf import settings +from firetower.incidents.hooks import get_postmortem_scheduler_bot_user_id from firetower.incidents.models import Incident, IncidentStatus from firetower.incidents.tasks.decorators import datadog_log from firetower.integrations.services.slack import SlackService @@ -39,9 +40,7 @@ def send_postmortem_attendee_followup( ) ) - bot_user_id = settings.SLACK.get( - "POSTMORTEM_SCHEDULER_BOT_USER_ID" - ) or settings.SLACK.get("TRIAGE_BOT_USER_ID", "") + bot_user_id = get_postmortem_scheduler_bot_user_id() if bot_user_id: slack.invite_to_channel(channel_id, [bot_user_id]) text = f"<@{bot_user_id}> {text}" diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index aa91e6a5..1a3cd187 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -30,6 +30,7 @@ on_title_changed, on_visibility_changed, page_for_channel, + record_postmortem_meeting, resolve_pages_for_incident, schedule_statuspage_followup_reminder, start_postmortem_attendee_collection, @@ -40,6 +41,8 @@ Incident, IncidentSeverity, IncidentStatus, + TimelineEvent, + TimelineEventType, ) @@ -4262,3 +4265,63 @@ def test_on_incident_updated_ignores_other_transitions( on_incident_updated(incident, old_status=IncidentStatus.POSTMORTEM) mock_start.assert_not_called() + + +@pytest.mark.django_db +class TestRecordPostmortemMeeting: + MEET_URL = "https://meet.google.com/abc-defg-hij" + + def _meeting_events(self, incident): + return TimelineEvent.objects.filter( + incident=incident, + event_type=TimelineEventType.POSTMORTEM_MEETING_SCHEDULED, + ) + + def test_saves_link_and_records_timeline_event(self): + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + status=IncidentStatus.POSTMORTEM, + ) + + record_postmortem_meeting(incident, self.MEET_URL) + + link = ExternalLink.objects.get( + incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING + ) + assert link.url == self.MEET_URL + event = self._meeting_events(incident).get() + assert event.link_url == self.MEET_URL + + def test_same_link_is_recorded_once(self): + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + status=IncidentStatus.POSTMORTEM, + ) + + record_postmortem_meeting(incident, self.MEET_URL) + record_postmortem_meeting(incident, self.MEET_URL) + + assert self._meeting_events(incident).count() == 1 + + def test_new_link_replaces_previous(self): + incident = Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + status=IncidentStatus.POSTMORTEM, + ) + new_url = "https://meet.google.com/xyz-wxyz-xyz" + + record_postmortem_meeting(incident, self.MEET_URL) + record_postmortem_meeting(incident, new_url) + + link = ExternalLink.objects.get( + incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING + ) + assert link.url == new_url + assert list( + self._meeting_events(incident) + .order_by("id") + .values_list("link_url", flat=True) + ) == [self.MEET_URL, new_url] diff --git a/src/firetower/incidents/tests/test_models.py b/src/firetower/incidents/tests/test_models.py index f8d348f0..40de88d9 100644 --- a/src/firetower/incidents/tests/test_models.py +++ b/src/firetower/incidents/tests/test_models.py @@ -350,7 +350,7 @@ def test_enums_and_string(self): "STATUSPAGE", "PAGERDUTY", ] - assert len(TimelineEventType.values) == 10 + assert len(TimelineEventType.values) == 11 assert str(event) == f"{incident.incident_number}: Incident created" def test_ordering_cascade_and_actor_set_null(self): diff --git a/src/firetower/incidents/tests/test_timeline.py b/src/firetower/incidents/tests/test_timeline.py index a0bf8af3..3f7c577b 100644 --- a/src/firetower/incidents/tests/test_timeline.py +++ b/src/firetower/incidents/tests/test_timeline.py @@ -391,6 +391,11 @@ class TestTimelineRenderer: {"service": "Backend"}, "PagerDuty page triggered for Backend", ), + ( + TimelineEventType.POSTMORTEM_MEETING_SCHEDULED, + {}, + "Postmortem meeting scheduled", + ), ], ) def test_renders_every_declared_event_type(self, event_type, payload, expected): diff --git a/src/firetower/incidents/timeline/events.py b/src/firetower/incidents/timeline/events.py index ab27eb58..fad1ef55 100644 --- a/src/firetower/incidents/timeline/events.py +++ b/src/firetower/incidents/timeline/events.py @@ -239,3 +239,16 @@ def record_statuspage_update_posted( link_url=sp_url, external_id=sp_update_id, ) + + +def record_postmortem_meeting_scheduled( + incident: Incident, *, meeting_url: str +) -> TimelineEvent: + return _record_event( + incident, + TimelineEventType.POSTMORTEM_MEETING_SCHEDULED, + {}, + actor=None, + occurred_at=None, + link_url=meeting_url, + ) diff --git a/src/firetower/incidents/timeline/render.py b/src/firetower/incidents/timeline/render.py index a716237c..7951e3a7 100644 --- a/src/firetower/incidents/timeline/render.py +++ b/src/firetower/incidents/timeline/render.py @@ -61,6 +61,8 @@ def render_timeline_event(event: TimelineEvent) -> str: case TimelineEventType.PAGERDUTY_INCIDENT_TRIGGERED: pagerduty = cast(PagerDutyPayload, event.payload) return f"PagerDuty page triggered for {pagerduty['service']}" + case TimelineEventType.POSTMORTEM_MEETING_SCHEDULED: + return "Postmortem meeting scheduled" case _: assert_never(event_type) except (KeyError, TypeError, ValueError): diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 747bea0f..59d58045 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -33,6 +33,10 @@ handle_severity_action, handle_tag_options, ) +from firetower.slack_app.handlers.postmortem_meeting import ( + handle_postmortem_meeting_message, + is_postmortem_meeting_message, +) from firetower.slack_app.handlers.reopen import handle_reopen_command from firetower.slack_app.handlers.resolved import ( handle_resolved_command, @@ -296,9 +300,9 @@ def _ignore_message_event(ack: Any) -> None: Subscribing to message.channels/message.groups opts the bot into the full message firehose. Without a catch-all listener Bolt logs an "Unhandled - request" warning for every non-topic message. This listener is registered - after the channel_topic handler, so for topic changes the specific handler - runs first and returns before this one is reached. + request" warning for every unmatched message. This listener is registered + after the specific message handlers, so they run first and this one is only + reached when none of them match. """ ack() @@ -308,4 +312,7 @@ def _register_event_handlers(app: App) -> None: app.event({"type": "message", "subtype": "channel_topic"})( _with_event_metrics("channel_topic")(handle_channel_topic_change) ) + app.event("message", matchers=[is_postmortem_meeting_message])( + _with_event_metrics("postmortem_meeting")(handle_postmortem_meeting_message) + ) app.event("message")(_ignore_message_event) diff --git a/src/firetower/slack_app/handlers/postmortem_meeting.py b/src/firetower/slack_app/handlers/postmortem_meeting.py new file mode 100644 index 00000000..69baa40d --- /dev/null +++ b/src/firetower/slack_app/handlers/postmortem_meeting.py @@ -0,0 +1,43 @@ +import logging +import re + +from firetower.incidents.hooks import ( + get_postmortem_scheduler_bot_user_id, + record_postmortem_meeting, +) +from firetower.incidents.models import IncidentStatus +from firetower.slack_app.handlers.utils import get_incident_from_channel + +logger = logging.getLogger(__name__) + +MEET_URL_PATTERN = re.compile(r"https://meet\.google\.com/[a-z]+-[a-z]+-[a-z]+") + + +def is_postmortem_meeting_message(event: dict) -> bool: + """Match scheduler bot messages that contain a Google Meet link.""" + bot_user_id = get_postmortem_scheduler_bot_user_id() + return ( + bool(bot_user_id) + and event.get("user") == bot_user_id + and MEET_URL_PATTERN.search(event.get("text", "")) is not None + ) + + +def handle_postmortem_meeting_message(event: dict) -> None: + """Record the meeting link the scheduler bot posted for a postmortem incident.""" + match = MEET_URL_PATTERN.search(event.get("text", "")) + channel_id = event.get("channel") + if not match or not channel_id: + return + + incident = get_incident_from_channel(channel_id) + if incident is None or incident.status not in ( + IncidentStatus.POSTMORTEM, + IncidentStatus.DONE, + ): + return + + logger.info( + "Recording postmortem meeting link for incident %s", incident.incident_number + ) + record_postmortem_meeting(incident, match.group(0)) diff --git a/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py b/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py new file mode 100644 index 00000000..8058c49f --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py @@ -0,0 +1,66 @@ +from unittest.mock import patch + +import pytest +from django.conf import settings + +from firetower.incidents.models import ExternalLinkType, IncidentStatus +from firetower.slack_app.handlers.postmortem_meeting import ( + handle_postmortem_meeting_message, + is_postmortem_meeting_message, +) + +from .conftest import CHANNEL_ID + +SCHEDULER_BOT_ID = "U_SCHEDULER" +MEET_URL = "https://meet.google.com/abc-defg-hij" + + +@pytest.fixture(autouse=True) +def _configure_scheduler_bot(): + slack_settings = { + **settings.SLACK, + "POSTMORTEM_SCHEDULER_BOT_USER_ID": SCHEDULER_BOT_ID, + } + with patch.object(settings, "SLACK", slack_settings): + yield + + +def _event(user=SCHEDULER_BOT_ID, text=f"Scheduled: <{MEET_URL}|Join>"): + return {"type": "message", "channel": CHANNEL_ID, "user": user, "text": text} + + +class TestIsPostmortemMeetingMessage: + def test_matches_scheduler_bot_message_with_meet_link(self): + assert is_postmortem_meeting_message(_event()) + + def test_ignores_other_users(self): + assert not is_postmortem_meeting_message(_event(user="U_OTHER")) + + def test_ignores_messages_without_meet_link(self): + assert not is_postmortem_meeting_message(_event(text="Scheduled!")) + + def test_ignores_when_no_scheduler_bot_configured(self): + settings.SLACK["POSTMORTEM_SCHEDULER_BOT_USER_ID"] = "" + settings.SLACK["TRIAGE_BOT_USER_ID"] = "" + assert not is_postmortem_meeting_message(_event(user="")) + + +@pytest.mark.django_db +class TestHandlePostmortemMeetingMessage: + def test_records_meeting_for_postmortem_incident(self, incident): + incident.status = IncidentStatus.POSTMORTEM + incident.save() + + handle_postmortem_meeting_message(_event()) + + assert ( + incident.external_links.get(type=ExternalLinkType.POSTMORTEM_MEETING).url + == MEET_URL + ) + + def test_ignores_active_incident(self, incident): + handle_postmortem_meeting_message(_event()) + + assert not incident.external_links.filter( + type=ExternalLinkType.POSTMORTEM_MEETING + ).exists() From b8eddcf5c8db7ce7e9384df47a886121d18a463e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:06:19 +0000 Subject: [PATCH 4/9] ref(incidents): Drop postmortem meeting timeline event Co-Authored-By: Richard Gibert --- config.example.toml | 2 +- .../queries/incidentTimelineQueryOptions.ts | 1 - src/firetower/incidents/hooks.py | 20 +++---- .../0026_add_postmortem_meeting_link_type.py | 32 ++++++++++++ .../migrations/0026_postmortem_meeting.py | 52 ------------------- src/firetower/incidents/models.py | 4 -- src/firetower/incidents/tests/test_hooks.py | 40 +++----------- src/firetower/incidents/tests/test_models.py | 2 +- .../incidents/tests/test_timeline.py | 5 -- src/firetower/incidents/timeline/events.py | 13 ----- src/firetower/incidents/timeline/render.py | 2 - 11 files changed, 46 insertions(+), 127 deletions(-) create mode 100644 src/firetower/incidents/migrations/0026_add_postmortem_meeting_link_type.py delete mode 100644 src/firetower/incidents/migrations/0026_postmortem_meeting.py diff --git a/config.example.toml b/config.example.toml index 9ba828d3..7e6b6a44 100644 --- a/config.example.toml +++ b/config.example.toml @@ -70,7 +70,7 @@ triage_bot_prompt = "We've been alerted by {alert_url}, run incident triage for # postmortem_attendee_collection_minutes = 10 postmortem_scheduler_bot_user_id = "" # When the scheduler bot replies in the incident channel with a Google Meet link, Firetower saves it -# as the incident's postmortem meeting link and adds it to the timeline. +# as the incident's postmortem meeting link. postmortem_scheduler_bot_prompt = "Schedule the {incident_number} postmortem meeting with everyone who reacted to {prompt_url}, then reply with the Google Meet link." [auth] diff --git a/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts index 97fb2fc3..635222f4 100644 --- a/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts +++ b/frontend/src/routes/$incidentId/queries/incidentTimelineQueryOptions.ts @@ -15,7 +15,6 @@ const TimelineEventTypeSchema = z.enum([ 'statuspage_incident_created', 'statuspage_update_posted', 'pagerduty_incident_triggered', - 'postmortem_meeting_scheduled', ]); const TimelineActorSchema = z.object({ diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 516d5cde..2f26eae6 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -24,7 +24,6 @@ get_linear_parent_issue_state_id, sync_linear_parent_issue_status, ) -from firetower.incidents.timeline.events import record_postmortem_meeting_scheduled from firetower.integrations.services import ( DatadogService, LinearService, @@ -1444,19 +1443,12 @@ def start_postmortem_attendee_collection(incident: Incident, channel_id: str) -> def record_postmortem_meeting(incident: Incident, meeting_url: str) -> None: - """Save the postmortem meeting link on the incident and add it to the timeline.""" - with transaction.atomic(): - link, created = ExternalLink.objects.get_or_create( - incident=incident, - type=ExternalLinkType.POSTMORTEM_MEETING, - defaults={"url": meeting_url}, - ) - if not created: - if link.url == meeting_url: - return - link.url = meeting_url - link.save(update_fields=["url"]) - record_postmortem_meeting_scheduled(incident, meeting_url=meeting_url) + """Save the postmortem meeting link on the incident.""" + ExternalLink.objects.update_or_create( + incident=incident, + type=ExternalLinkType.POSTMORTEM_MEETING, + defaults={"url": meeting_url}, + ) def cancel_statuspage_followup_reminder(incident: Incident) -> None: diff --git a/src/firetower/incidents/migrations/0026_add_postmortem_meeting_link_type.py b/src/firetower/incidents/migrations/0026_add_postmortem_meeting_link_type.py new file mode 100644 index 00000000..107ab89a --- /dev/null +++ b/src/firetower/incidents/migrations/0026_add_postmortem_meeting_link_type.py @@ -0,0 +1,32 @@ +# Generated by Django 6.1.2 on 2026-10-28 16:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "0025_alter_actionitem_created_at"), + ] + + operations = [ + migrations.AlterField( + model_name="externallink", + name="type", + field=models.CharField( + choices=[ + ("SLACK", "Slack"), + ("SLACK_STATUS", "Slack Status"), + ("JIRA", "Jira"), + ("DATADOG", "Datadog"), + ("PAGERDUTY", "PagerDuty"), + ("STATUSPAGE", "Statuspage"), + ("NOTION", "Notion"), + ("NOTION_TROUBLESHOOTING", "Notion Troubleshooting"), + ("LINEAR", "Linear"), + ("MEETING_RECORDING", "Meeting Recording"), + ("POSTMORTEM_MEETING", "Postmortem Meeting"), + ], + max_length=30, + ), + ), + ] diff --git a/src/firetower/incidents/migrations/0026_postmortem_meeting.py b/src/firetower/incidents/migrations/0026_postmortem_meeting.py deleted file mode 100644 index e6760946..00000000 --- a/src/firetower/incidents/migrations/0026_postmortem_meeting.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 6.1.2 on 2026-10-28 15:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("incidents", "0025_alter_actionitem_created_at"), - ] - - operations = [ - migrations.AlterField( - model_name="externallink", - name="type", - field=models.CharField( - choices=[ - ("SLACK", "Slack"), - ("SLACK_STATUS", "Slack Status"), - ("JIRA", "Jira"), - ("DATADOG", "Datadog"), - ("PAGERDUTY", "PagerDuty"), - ("STATUSPAGE", "Statuspage"), - ("NOTION", "Notion"), - ("NOTION_TROUBLESHOOTING", "Notion Troubleshooting"), - ("LINEAR", "Linear"), - ("MEETING_RECORDING", "Meeting Recording"), - ("POSTMORTEM_MEETING", "Postmortem Meeting"), - ], - max_length=30, - ), - ), - migrations.AlterField( - model_name="timelineevent", - name="event_type", - field=models.CharField( - choices=[ - ("note", "Note"), - ("incident_created", "Incident created"), - ("status_changed", "Status changed"), - ("severity_changed", "Severity changed"), - ("captain_changed", "Captain changed"), - ("title_changed", "Title changed"), - ("visibility_changed", "Visibility changed"), - ("statuspage_incident_created", "Statuspage incident created"), - ("statuspage_update_posted", "Statuspage update posted"), - ("pagerduty_incident_triggered", "PagerDuty incident triggered"), - ("postmortem_meeting_scheduled", "Postmortem meeting scheduled"), - ], - max_length=64, - ), - ), - ] diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index cecb922f..bbed60f4 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -87,10 +87,6 @@ class TimelineEventType(models.TextChoices): "pagerduty_incident_triggered", "PagerDuty incident triggered", ) - POSTMORTEM_MEETING_SCHEDULED = ( - "postmortem_meeting_scheduled", - "Postmortem meeting scheduled", - ) class ServiceTier(models.TextChoices): diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index 1a3cd187..17baa467 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -41,8 +41,6 @@ Incident, IncidentSeverity, IncidentStatus, - TimelineEvent, - TimelineEventType, ) @@ -4271,46 +4269,25 @@ def test_on_incident_updated_ignores_other_transitions( class TestRecordPostmortemMeeting: MEET_URL = "https://meet.google.com/abc-defg-hij" - def _meeting_events(self, incident): - return TimelineEvent.objects.filter( - incident=incident, - event_type=TimelineEventType.POSTMORTEM_MEETING_SCHEDULED, - ) - - def test_saves_link_and_records_timeline_event(self): - incident = Incident.objects.create( + def _make_incident(self): + return Incident.objects.create( title="Test Incident", severity=IncidentSeverity.P1, status=IncidentStatus.POSTMORTEM, ) + def test_saves_link(self): + incident = self._make_incident() + record_postmortem_meeting(incident, self.MEET_URL) link = ExternalLink.objects.get( incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING ) assert link.url == self.MEET_URL - event = self._meeting_events(incident).get() - assert event.link_url == self.MEET_URL - - def test_same_link_is_recorded_once(self): - incident = Incident.objects.create( - title="Test Incident", - severity=IncidentSeverity.P1, - status=IncidentStatus.POSTMORTEM, - ) - - record_postmortem_meeting(incident, self.MEET_URL) - record_postmortem_meeting(incident, self.MEET_URL) - - assert self._meeting_events(incident).count() == 1 def test_new_link_replaces_previous(self): - incident = Incident.objects.create( - title="Test Incident", - severity=IncidentSeverity.P1, - status=IncidentStatus.POSTMORTEM, - ) + incident = self._make_incident() new_url = "https://meet.google.com/xyz-wxyz-xyz" record_postmortem_meeting(incident, self.MEET_URL) @@ -4320,8 +4297,3 @@ def test_new_link_replaces_previous(self): incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING ) assert link.url == new_url - assert list( - self._meeting_events(incident) - .order_by("id") - .values_list("link_url", flat=True) - ) == [self.MEET_URL, new_url] diff --git a/src/firetower/incidents/tests/test_models.py b/src/firetower/incidents/tests/test_models.py index 40de88d9..f8d348f0 100644 --- a/src/firetower/incidents/tests/test_models.py +++ b/src/firetower/incidents/tests/test_models.py @@ -350,7 +350,7 @@ def test_enums_and_string(self): "STATUSPAGE", "PAGERDUTY", ] - assert len(TimelineEventType.values) == 11 + assert len(TimelineEventType.values) == 10 assert str(event) == f"{incident.incident_number}: Incident created" def test_ordering_cascade_and_actor_set_null(self): diff --git a/src/firetower/incidents/tests/test_timeline.py b/src/firetower/incidents/tests/test_timeline.py index 3f7c577b..a0bf8af3 100644 --- a/src/firetower/incidents/tests/test_timeline.py +++ b/src/firetower/incidents/tests/test_timeline.py @@ -391,11 +391,6 @@ class TestTimelineRenderer: {"service": "Backend"}, "PagerDuty page triggered for Backend", ), - ( - TimelineEventType.POSTMORTEM_MEETING_SCHEDULED, - {}, - "Postmortem meeting scheduled", - ), ], ) def test_renders_every_declared_event_type(self, event_type, payload, expected): diff --git a/src/firetower/incidents/timeline/events.py b/src/firetower/incidents/timeline/events.py index fad1ef55..ab27eb58 100644 --- a/src/firetower/incidents/timeline/events.py +++ b/src/firetower/incidents/timeline/events.py @@ -239,16 +239,3 @@ def record_statuspage_update_posted( link_url=sp_url, external_id=sp_update_id, ) - - -def record_postmortem_meeting_scheduled( - incident: Incident, *, meeting_url: str -) -> TimelineEvent: - return _record_event( - incident, - TimelineEventType.POSTMORTEM_MEETING_SCHEDULED, - {}, - actor=None, - occurred_at=None, - link_url=meeting_url, - ) diff --git a/src/firetower/incidents/timeline/render.py b/src/firetower/incidents/timeline/render.py index 7951e3a7..a716237c 100644 --- a/src/firetower/incidents/timeline/render.py +++ b/src/firetower/incidents/timeline/render.py @@ -61,8 +61,6 @@ def render_timeline_event(event: TimelineEvent) -> str: case TimelineEventType.PAGERDUTY_INCIDENT_TRIGGERED: pagerduty = cast(PagerDutyPayload, event.payload) return f"PagerDuty page triggered for {pagerduty['service']}" - case TimelineEventType.POSTMORTEM_MEETING_SCHEDULED: - return "Postmortem meeting scheduled" case _: assert_never(event_type) except (KeyError, TypeError, ValueError): From 9d3d7b3e437694af6a4f88f3a31404737528c8dc Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:13:44 +0000 Subject: [PATCH 5/9] ref(incidents): Rename meeting recording link to postmortem meeting Co-Authored-By: Richard Gibert --- ...ng_recording.svg => postmortem_meeting.svg} | 0 .../$incidentId/components/LinksList.tsx | 6 +----- .../queries/incidentDetailQueryOptions.ts | 1 - ...meeting_recording_to_postmortem_meeting.py} | 18 ++++++++++++++++-- src/firetower/incidents/models.py | 1 - 5 files changed, 17 insertions(+), 9 deletions(-) rename frontend/public/{meeting_recording.svg => postmortem_meeting.svg} (100%) rename src/firetower/incidents/migrations/{0026_add_postmortem_meeting_link_type.py => 0026_rename_meeting_recording_to_postmortem_meeting.py} (61%) diff --git a/frontend/public/meeting_recording.svg b/frontend/public/postmortem_meeting.svg similarity index 100% rename from frontend/public/meeting_recording.svg rename to frontend/public/postmortem_meeting.svg diff --git a/frontend/src/routes/$incidentId/components/LinksList.tsx b/frontend/src/routes/$incidentId/components/LinksList.tsx index 7bbf3576..143916b6 100644 --- a/frontend/src/routes/$incidentId/components/LinksList.tsx +++ b/frontend/src/routes/$incidentId/components/LinksList.tsx @@ -29,13 +29,9 @@ const linkConfigs = { label: 'PagerDuty', icon: '/pagerduty.svg', }, - meeting_recording: { - label: 'Meeting Recording', - icon: '/meeting_recording.svg', - }, postmortem_meeting: { label: 'Postmortem meeting', - icon: '/meeting_recording.svg', + icon: '/postmortem_meeting.svg', }, statuspage: { label: 'Statuspage', diff --git a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts index acd2f931..ba740283 100644 --- a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts +++ b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts @@ -20,7 +20,6 @@ const ExternalLinksSchema = z.object({ notion: z.string().optional(), notion_troubleshooting: z.string().optional(), linear: z.string().optional(), - meeting_recording: z.string().optional(), postmortem_meeting: z.string().optional(), }); diff --git a/src/firetower/incidents/migrations/0026_add_postmortem_meeting_link_type.py b/src/firetower/incidents/migrations/0026_rename_meeting_recording_to_postmortem_meeting.py similarity index 61% rename from src/firetower/incidents/migrations/0026_add_postmortem_meeting_link_type.py rename to src/firetower/incidents/migrations/0026_rename_meeting_recording_to_postmortem_meeting.py index 107ab89a..cdc5681d 100644 --- a/src/firetower/incidents/migrations/0026_add_postmortem_meeting_link_type.py +++ b/src/firetower/incidents/migrations/0026_rename_meeting_recording_to_postmortem_meeting.py @@ -1,8 +1,22 @@ -# Generated by Django 6.1.2 on 2026-10-28 16:04 +# Generated by Django 6.1.2 on 2026-10-28 16:10 from django.db import migrations, models +def rename_meeting_recording(apps, schema_editor): + ExternalLink = apps.get_model("incidents", "ExternalLink") + ExternalLink.objects.filter(type="MEETING_RECORDING").update( + type="POSTMORTEM_MEETING" + ) + + +def restore_meeting_recording(apps, schema_editor): + ExternalLink = apps.get_model("incidents", "ExternalLink") + ExternalLink.objects.filter(type="POSTMORTEM_MEETING").update( + type="MEETING_RECORDING" + ) + + class Migration(migrations.Migration): dependencies = [ ("incidents", "0025_alter_actionitem_created_at"), @@ -23,10 +37,10 @@ class Migration(migrations.Migration): ("NOTION", "Notion"), ("NOTION_TROUBLESHOOTING", "Notion Troubleshooting"), ("LINEAR", "Linear"), - ("MEETING_RECORDING", "Meeting Recording"), ("POSTMORTEM_MEETING", "Postmortem Meeting"), ], max_length=30, ), ), + migrations.RunPython(rename_meeting_recording, restore_meeting_recording), ] diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index bbed60f4..7da9b2c7 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -121,7 +121,6 @@ class ExternalLinkType(models.TextChoices): NOTION = "NOTION", "Notion" NOTION_TROUBLESHOOTING = "NOTION_TROUBLESHOOTING", "Notion Troubleshooting" LINEAR = "LINEAR", "Linear" - MEETING_RECORDING = "MEETING_RECORDING", "Meeting Recording" POSTMORTEM_MEETING = "POSTMORTEM_MEETING", "Postmortem Meeting" From 00ac296cf8f77bf0bbe0d0378efbf502a8357853 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:47:21 +0000 Subject: [PATCH 6/9] fix(incidents): Skip postmortem attendee collection for private incidents Private incidents should not post attendee prompts, schedule follow-ups, or invite and mention the scheduler bot, matching the existing triage-bot and statuspage reminder guards. --- src/firetower/incidents/hooks.py | 2 +- src/firetower/incidents/tasks/postmortem.py | 5 ++++- src/firetower/incidents/tests/test_hooks.py | 11 +++++++++++ src/firetower/incidents/tests/test_tasks.py | 8 ++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 2f26eae6..42c8bd2a 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -1408,7 +1408,7 @@ def _schedule_statuspage_reminder( def start_postmortem_attendee_collection(incident: Incident, channel_id: str) -> None: """Post a reaction prompt and schedule the follow-up that hands reactors off for scheduling.""" - if incident.severity not in HIGH_SEVERITIES: + if incident.is_private or incident.severity not in HIGH_SEVERITIES: return minutes = get_postmortem_attendee_collection_minutes() diff --git a/src/firetower/incidents/tasks/postmortem.py b/src/firetower/incidents/tasks/postmortem.py index 45fd8d55..0a5d9af9 100644 --- a/src/firetower/incidents/tasks/postmortem.py +++ b/src/firetower/incidents/tasks/postmortem.py @@ -28,7 +28,10 @@ def send_postmortem_attendee_followup( ) return - if incident.status not in (IncidentStatus.POSTMORTEM, IncidentStatus.DONE): + if incident.is_private or incident.status not in ( + IncidentStatus.POSTMORTEM, + IncidentStatus.DONE, + ): return slack = SlackService() diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index 17baa467..e6eb7b0a 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -4202,6 +4202,17 @@ def test_skips_low_severity(self, mock_slack): name__startswith="postmortem_attendee_collection_" ).exists() + @patch("firetower.incidents.hooks._slack_service") + def test_skips_private_incident(self, mock_slack): + incident = self._make_incident(is_private=True) + + start_postmortem_attendee_collection(incident, "C12345") + + mock_slack.post_message.assert_not_called() + assert not Schedule.objects.filter( + name__startswith="postmortem_attendee_collection_" + ).exists() + @patch("firetower.incidents.hooks._slack_service") def test_skips_when_not_configured(self, mock_slack, settings): settings.SLACK = { diff --git a/src/firetower/incidents/tests/test_tasks.py b/src/firetower/incidents/tests/test_tasks.py index 27f31f8b..90c22848 100644 --- a/src/firetower/incidents/tests/test_tasks.py +++ b/src/firetower/incidents/tests/test_tasks.py @@ -1317,6 +1317,14 @@ def test_skips_when_prompt_empty(self): mock_slack.post_message.assert_not_called() + def test_skips_private_incident(self): + incident = self._make_incident(is_private=True) + + mock_slack = self._run(incident) + + mock_slack.invite_to_channel.assert_not_called() + mock_slack.post_message.assert_not_called() + @pytest.mark.parametrize("status", [IncidentStatus.ACTIVE, IncidentStatus.CANCELED]) def test_skips_when_incident_left_postmortem(self, status): incident = self._make_incident(status=status) From c4949bed5b4e09c535d0a6e93b940ada10a30616 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:51:58 +0000 Subject: [PATCH 7/9] ref(incidents): Split postmortem meeting link and recording types Rename MEETING_RECORDING to POSTMORTEM_MEETING_RECORDING instead of folding it into the meeting link, and store the scheduler bot's Meet link under a separate POSTMORTEM_MEETING_LINK type. Co-Authored-By: Richard Gibert --- .../{postmortem_meeting.svg => meeting_recording.svg} | 0 .../src/routes/$incidentId/components/LinksList.tsx | 8 ++++++-- .../$incidentId/queries/incidentDetailQueryOptions.ts | 3 ++- src/firetower/incidents/hooks.py | 2 +- ...eeting.py => 0026_postmortem_meeting_link_types.py} | 10 +++++++--- src/firetower/incidents/models.py | 6 +++++- src/firetower/incidents/tests/test_hooks.py | 4 ++-- .../tests/handlers/test_postmortem_meeting.py | 6 ++++-- 8 files changed, 27 insertions(+), 12 deletions(-) rename frontend/public/{postmortem_meeting.svg => meeting_recording.svg} (100%) rename src/firetower/incidents/migrations/{0026_rename_meeting_recording_to_postmortem_meeting.py => 0026_postmortem_meeting_link_types.py} (79%) diff --git a/frontend/public/postmortem_meeting.svg b/frontend/public/meeting_recording.svg similarity index 100% rename from frontend/public/postmortem_meeting.svg rename to frontend/public/meeting_recording.svg diff --git a/frontend/src/routes/$incidentId/components/LinksList.tsx b/frontend/src/routes/$incidentId/components/LinksList.tsx index 143916b6..dcbb51e5 100644 --- a/frontend/src/routes/$incidentId/components/LinksList.tsx +++ b/frontend/src/routes/$incidentId/components/LinksList.tsx @@ -29,9 +29,13 @@ const linkConfigs = { label: 'PagerDuty', icon: '/pagerduty.svg', }, - postmortem_meeting: { + postmortem_meeting_link: { label: 'Postmortem meeting', - icon: '/postmortem_meeting.svg', + icon: '/meeting_recording.svg', + }, + postmortem_meeting_recording: { + label: 'Postmortem meeting recording', + icon: '/meeting_recording.svg', }, statuspage: { label: 'Statuspage', diff --git a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts index ba740283..f3cc5120 100644 --- a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts +++ b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts @@ -20,7 +20,8 @@ const ExternalLinksSchema = z.object({ notion: z.string().optional(), notion_troubleshooting: z.string().optional(), linear: z.string().optional(), - postmortem_meeting: z.string().optional(), + postmortem_meeting_recording: z.string().optional(), + postmortem_meeting_link: z.string().optional(), }); const IncidentDetailSchema = z.object({ diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 42c8bd2a..49b565d1 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -1446,7 +1446,7 @@ def record_postmortem_meeting(incident: Incident, meeting_url: str) -> None: """Save the postmortem meeting link on the incident.""" ExternalLink.objects.update_or_create( incident=incident, - type=ExternalLinkType.POSTMORTEM_MEETING, + type=ExternalLinkType.POSTMORTEM_MEETING_LINK, defaults={"url": meeting_url}, ) diff --git a/src/firetower/incidents/migrations/0026_rename_meeting_recording_to_postmortem_meeting.py b/src/firetower/incidents/migrations/0026_postmortem_meeting_link_types.py similarity index 79% rename from src/firetower/incidents/migrations/0026_rename_meeting_recording_to_postmortem_meeting.py rename to src/firetower/incidents/migrations/0026_postmortem_meeting_link_types.py index cdc5681d..4d872444 100644 --- a/src/firetower/incidents/migrations/0026_rename_meeting_recording_to_postmortem_meeting.py +++ b/src/firetower/incidents/migrations/0026_postmortem_meeting_link_types.py @@ -6,13 +6,13 @@ def rename_meeting_recording(apps, schema_editor): ExternalLink = apps.get_model("incidents", "ExternalLink") ExternalLink.objects.filter(type="MEETING_RECORDING").update( - type="POSTMORTEM_MEETING" + type="POSTMORTEM_MEETING_RECORDING" ) def restore_meeting_recording(apps, schema_editor): ExternalLink = apps.get_model("incidents", "ExternalLink") - ExternalLink.objects.filter(type="POSTMORTEM_MEETING").update( + ExternalLink.objects.filter(type="POSTMORTEM_MEETING_RECORDING").update( type="MEETING_RECORDING" ) @@ -37,7 +37,11 @@ class Migration(migrations.Migration): ("NOTION", "Notion"), ("NOTION_TROUBLESHOOTING", "Notion Troubleshooting"), ("LINEAR", "Linear"), - ("POSTMORTEM_MEETING", "Postmortem Meeting"), + ( + "POSTMORTEM_MEETING_RECORDING", + "Postmortem Meeting Recording", + ), + ("POSTMORTEM_MEETING_LINK", "Postmortem Meeting Link"), ], max_length=30, ), diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index 7da9b2c7..0246f712 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -121,7 +121,11 @@ class ExternalLinkType(models.TextChoices): NOTION = "NOTION", "Notion" NOTION_TROUBLESHOOTING = "NOTION_TROUBLESHOOTING", "Notion Troubleshooting" LINEAR = "LINEAR", "Linear" - POSTMORTEM_MEETING = "POSTMORTEM_MEETING", "Postmortem Meeting" + POSTMORTEM_MEETING_RECORDING = ( + "POSTMORTEM_MEETING_RECORDING", + "Postmortem Meeting Recording", + ) + POSTMORTEM_MEETING_LINK = "POSTMORTEM_MEETING_LINK", "Postmortem Meeting Link" class Tag(models.Model): diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index e6eb7b0a..ca06b671 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -4293,7 +4293,7 @@ def test_saves_link(self): record_postmortem_meeting(incident, self.MEET_URL) link = ExternalLink.objects.get( - incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING + incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING_LINK ) assert link.url == self.MEET_URL @@ -4305,6 +4305,6 @@ def test_new_link_replaces_previous(self): record_postmortem_meeting(incident, new_url) link = ExternalLink.objects.get( - incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING + incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING_LINK ) assert link.url == new_url diff --git a/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py b/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py index 8058c49f..c6bd0bad 100644 --- a/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py +++ b/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py @@ -54,7 +54,9 @@ def test_records_meeting_for_postmortem_incident(self, incident): handle_postmortem_meeting_message(_event()) assert ( - incident.external_links.get(type=ExternalLinkType.POSTMORTEM_MEETING).url + incident.external_links.get( + type=ExternalLinkType.POSTMORTEM_MEETING_LINK + ).url == MEET_URL ) @@ -62,5 +64,5 @@ def test_ignores_active_incident(self, incident): handle_postmortem_meeting_message(_event()) assert not incident.external_links.filter( - type=ExternalLinkType.POSTMORTEM_MEETING + type=ExternalLinkType.POSTMORTEM_MEETING_LINK ).exists() From 96e429eb521e5d0e4c81ea8a510f7ddc4ab20f78 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Thu, 29 Oct 2026 12:41:44 -0400 Subject: [PATCH 8/9] fixes --- config.example.toml | 8 +++--- src/firetower/config.py | 2 +- src/firetower/incidents/hooks.py | 17 +++++++------ src/firetower/incidents/tasks/postmortem.py | 13 ++++------ src/firetower/incidents/tests/test_hooks.py | 25 ++++++++++++++++--- src/firetower/incidents/tests/test_tasks.py | 6 ++--- src/firetower/integrations/services/slack.py | 13 +++++++--- .../integrations/tests/test_slack.py | 15 +++++++++-- src/firetower/settings.py | 4 +-- 9 files changed, 69 insertions(+), 34 deletions(-) diff --git a/config.example.toml b/config.example.toml index 7e6b6a44..7d83997c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -64,10 +64,10 @@ incident_guide_message = "This is the message posted whenever a new incident sla slash_command = "/inc" triage_bot_user_id = "" triage_bot_prompt = "We've been alerted by {alert_url}, run incident triage for this issue." -# Set to collect postmortem meeting attendees by reaction when a P0/P1 is resolved; omit to disable. -# After the window, Firetower posts the scheduler bot prompt, mentioning postmortem_scheduler_bot_user_id -# (or triage_bot_user_id when unset). -# postmortem_attendee_collection_minutes = 10 +# Set to collect postmortem meeting attendees by reaction for this many days when a P0/P1 is resolved; +# omit to disable. After the window, Firetower replies in the prompt's thread with the scheduler bot +# prompt, mentioning postmortem_scheduler_bot_user_id (or triage_bot_user_id when unset). +# postmortem_attendee_collection_days = 2 postmortem_scheduler_bot_user_id = "" # When the scheduler bot replies in the incident channel with a Google Meet link, Firetower saves it # as the incident's postmortem meeting link. diff --git a/src/firetower/config.py b/src/firetower/config.py index c4a7e00b..f29755ed 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -75,7 +75,7 @@ class SlackConfig: triage_bot_prompt: str = ( "We've been alerted by {alert_url}, run incident triage for this issue." ) - postmortem_attendee_collection_minutes: int | None = None + postmortem_attendee_collection_days: int | None = None postmortem_scheduler_bot_user_id: str = "" postmortem_scheduler_bot_prompt: str = ( "Schedule the {incident_number} postmortem meeting with everyone who " diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 49b565d1..3d2185d3 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -69,12 +69,12 @@ def _get_linear_service() -> LinearService: ":calendar: *Postmortem meeting*\n" "React to this message with any emoji to be invited to the postmortem " "meeting for *{incident_number}*. Collecting attendees for the next " - "*{minutes} minutes*." + "*{days} days*." ) -def get_postmortem_attendee_collection_minutes() -> int | None: - raw = settings.SLACK.get("POSTMORTEM_ATTENDEE_COLLECTION_MINUTES") +def get_postmortem_attendee_collection_days() -> int | None: + raw = settings.SLACK.get("POSTMORTEM_ATTENDEE_COLLECTION_DAYS") return int(raw) if raw is not None else None @@ -1411,14 +1411,17 @@ def start_postmortem_attendee_collection(incident: Incident, channel_id: str) -> if incident.is_private or incident.severity not in HIGH_SEVERITIES: return - minutes = get_postmortem_attendee_collection_minutes() - if minutes is None: + if not get_postmortem_scheduler_bot_user_id(): + return + + days = get_postmortem_attendee_collection_days() + if days is None: return message_ts = _slack_service.post_message( channel_id, POSTMORTEM_ATTENDEE_PROMPT_MESSAGE.format( - incident_number=incident.incident_number, minutes=minutes + incident_number=incident.incident_number, days=days ), ) if not message_ts: @@ -1436,7 +1439,7 @@ def start_postmortem_attendee_collection(incident: Incident, channel_id: str) -> } ), "schedule_type": Schedule.ONCE, - "next_run": timezone.now() + timedelta(minutes=minutes), + "next_run": timezone.now() + timedelta(days=days), "repeats": -1, }, ) diff --git a/src/firetower/incidents/tasks/postmortem.py b/src/firetower/incidents/tasks/postmortem.py index 0a5d9af9..c262b7c8 100644 --- a/src/firetower/incidents/tasks/postmortem.py +++ b/src/firetower/incidents/tasks/postmortem.py @@ -17,7 +17,8 @@ def send_postmortem_attendee_followup( ) -> None: """Close the reaction window by posting the scheduler bot prompt for the reactors.""" prompt = settings.SLACK.get("POSTMORTEM_SCHEDULER_BOT_PROMPT", "") - if not prompt: + bot_user_id = get_postmortem_scheduler_bot_user_id() + if not prompt or not bot_user_id: return try: @@ -35,7 +36,7 @@ def send_postmortem_attendee_followup( return slack = SlackService() - text = prompt.format_map( + text = f"<@{bot_user_id}> " + prompt.format_map( defaultdict( str, incident_number=incident.incident_number, @@ -43,9 +44,5 @@ def send_postmortem_attendee_followup( ) ) - bot_user_id = get_postmortem_scheduler_bot_user_id() - if bot_user_id: - slack.invite_to_channel(channel_id, [bot_user_id]) - text = f"<@{bot_user_id}> {text}" - - slack.post_message(channel_id, text) + slack.invite_to_channel(channel_id, [bot_user_id]) + slack.post_message(channel_id, text, thread_ts=message_ts) diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index ca06b671..2c12c4a9 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -4152,7 +4152,8 @@ class TestPostmortemAttendeeCollection: def _configure_collection(self, settings): settings.SLACK = { **settings.SLACK, - "POSTMORTEM_ATTENDEE_COLLECTION_MINUTES": 10, + "POSTMORTEM_ATTENDEE_COLLECTION_DAYS": 2, + "POSTMORTEM_SCHEDULER_BOT_USER_ID": "U_SCHEDULER", } def _make_incident(self, **kwargs): @@ -4174,7 +4175,7 @@ def test_posts_prompt_and_schedules_collection(self, mock_slack): prompt = mock_slack.post_message.call_args[0][1] assert incident.incident_number in prompt - assert "10 minutes" in prompt + assert "2 days" in prompt schedule = Schedule.objects.get( name=f"postmortem_attendee_collection_{incident.id}" @@ -4189,7 +4190,7 @@ def test_posts_prompt_and_schedules_collection(self, mock_slack): "channel_id": "C12345", "message_ts": "1111.2222", } - assert schedule.next_run >= before + timedelta(minutes=10) + assert schedule.next_run >= before + timedelta(days=2) @patch("firetower.incidents.hooks._slack_service") def test_skips_low_severity(self, mock_slack): @@ -4217,7 +4218,23 @@ def test_skips_private_incident(self, mock_slack): def test_skips_when_not_configured(self, mock_slack, settings): settings.SLACK = { **settings.SLACK, - "POSTMORTEM_ATTENDEE_COLLECTION_MINUTES": None, + "POSTMORTEM_ATTENDEE_COLLECTION_DAYS": None, + } + incident = self._make_incident() + + start_postmortem_attendee_collection(incident, "C12345") + + mock_slack.post_message.assert_not_called() + assert not Schedule.objects.filter( + name__startswith="postmortem_attendee_collection_" + ).exists() + + @patch("firetower.incidents.hooks._slack_service") + def test_skips_when_no_scheduler_bot(self, mock_slack, settings): + settings.SLACK = { + **settings.SLACK, + "POSTMORTEM_SCHEDULER_BOT_USER_ID": "", + "TRIAGE_BOT_USER_ID": "", } incident = self._make_incident() diff --git a/src/firetower/incidents/tests/test_tasks.py b/src/firetower/incidents/tests/test_tasks.py index 90c22848..37961f87 100644 --- a/src/firetower/incidents/tests/test_tasks.py +++ b/src/firetower/incidents/tests/test_tasks.py @@ -1285,6 +1285,7 @@ def test_invites_and_mentions_scheduler_bot(self): "C12345", f"<@U_SCHEDULER> Schedule {incident.incident_number} with reactors on " "https://sentry.slack.com/archives/C12345/p11112222", + thread_ts="1111.2222", ) def test_falls_back_to_triage_bot(self): @@ -1298,7 +1299,7 @@ def test_falls_back_to_triage_bot(self): message = mock_slack.post_message.call_args[0][1] assert message.startswith("<@U_TRIAGE> ") - def test_posts_prompt_without_mention_when_no_bot(self): + def test_skips_when_no_bot(self): incident = self._make_incident() settings.SLACK["POSTMORTEM_SCHEDULER_BOT_USER_ID"] = "" settings.SLACK["TRIAGE_BOT_USER_ID"] = "" @@ -1306,8 +1307,7 @@ def test_posts_prompt_without_mention_when_no_bot(self): mock_slack = self._run(incident) mock_slack.invite_to_channel.assert_not_called() - message = mock_slack.post_message.call_args[0][1] - assert message.startswith(f"Schedule {incident.incident_number}") + mock_slack.post_message.assert_not_called() def test_skips_when_prompt_empty(self): incident = self._make_incident() diff --git a/src/firetower/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index 84ca69f0..b00c9276 100644 --- a/src/firetower/integrations/services/slack.py +++ b/src/firetower/integrations/services/slack.py @@ -362,7 +362,11 @@ def join_channel(self, channel_id: str) -> bool: return False def post_message( - self, channel_id: str, text: str, blocks: list[dict] | None = None + self, + channel_id: str, + text: str, + blocks: list[dict] | None = None, + thread_ts: str | None = None, ) -> str | None: if not self.client: logger.warning("Cannot post message - Slack client not initialized") @@ -371,7 +375,7 @@ def post_message( try: logger.info(f"Posting message to channel {channel_id}") response = self.client.chat_postMessage( - channel=channel_id, text=text, blocks=blocks + channel=channel_id, text=text, blocks=blocks, thread_ts=thread_ts ) return response.get("ts") except SlackApiError as e: @@ -384,7 +388,10 @@ def post_message( if self.join_channel(channel_id): try: response = self.client.chat_postMessage( - channel=channel_id, text=text, blocks=blocks + channel=channel_id, + text=text, + blocks=blocks, + thread_ts=thread_ts, ) return response.get("ts") except SlackApiError as retry_error: diff --git a/src/firetower/integrations/tests/test_slack.py b/src/firetower/integrations/tests/test_slack.py index 4e07b5e2..f3162ab8 100644 --- a/src/firetower/integrations/tests/test_slack.py +++ b/src/firetower/integrations/tests/test_slack.py @@ -403,7 +403,7 @@ def test_post_message_success(self): mock_client.chat_postMessage.return_value = {"ts": "1111.2222"} assert service.post_message("C12345", "hello") == "1111.2222" mock_client.chat_postMessage.assert_called_once_with( - channel="C12345", text="hello", blocks=None + channel="C12345", text="hello", blocks=None, thread_ts=None ) def test_post_message_with_blocks(self): @@ -412,7 +412,18 @@ def test_post_message_with_blocks(self): mock_client.chat_postMessage.return_value = {"ts": "1111.2222"} assert service.post_message("C12345", "hello", blocks=blocks) == "1111.2222" mock_client.chat_postMessage.assert_called_once_with( - channel="C12345", text="hello", blocks=blocks + channel="C12345", text="hello", blocks=blocks, thread_ts=None + ) + + def test_post_message_in_thread(self): + service, mock_client = self._make_service() + mock_client.chat_postMessage.return_value = {"ts": "3333.4444"} + assert ( + service.post_message("C12345", "hello", thread_ts="1111.2222") + == "3333.4444" + ) + mock_client.chat_postMessage.assert_called_once_with( + channel="C12345", text="hello", blocks=None, thread_ts="1111.2222" ) def test_post_message_no_client(self): diff --git a/src/firetower/settings.py b/src/firetower/settings.py index ede73a51..e6e5b5e9 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -262,7 +262,7 @@ class SlackSettings(TypedDict): SLASH_COMMAND: str TRIAGE_BOT_USER_ID: str TRIAGE_BOT_PROMPT: str - POSTMORTEM_ATTENDEE_COLLECTION_MINUTES: int | None + POSTMORTEM_ATTENDEE_COLLECTION_DAYS: int | None POSTMORTEM_SCHEDULER_BOT_USER_ID: str POSTMORTEM_SCHEDULER_BOT_PROMPT: str @@ -277,7 +277,7 @@ class SlackSettings(TypedDict): "SLASH_COMMAND": config.slack.slash_command, "TRIAGE_BOT_USER_ID": config.slack.triage_bot_user_id, "TRIAGE_BOT_PROMPT": config.slack.triage_bot_prompt, - "POSTMORTEM_ATTENDEE_COLLECTION_MINUTES": config.slack.postmortem_attendee_collection_minutes, + "POSTMORTEM_ATTENDEE_COLLECTION_DAYS": config.slack.postmortem_attendee_collection_days, "POSTMORTEM_SCHEDULER_BOT_USER_ID": config.slack.postmortem_scheduler_bot_user_id, "POSTMORTEM_SCHEDULER_BOT_PROMPT": config.slack.postmortem_scheduler_bot_prompt, } From 9558cbf845f6c8ffffcf2e95df33fc6c28ef6774 Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Tue, 3 Nov 2026 10:26:03 -0500 Subject: [PATCH 9/9] retrigger day before --- config.example.toml | 7 +- src/firetower/config.py | 7 +- src/firetower/incidents/hooks.py | 28 +++++++ src/firetower/incidents/tasks/__init__.py | 6 +- src/firetower/incidents/tasks/postmortem.py | 48 ++++++++--- src/firetower/incidents/tests/test_hooks.py | 53 ++++++++++++ src/firetower/incidents/tests/test_tasks.py | 80 +++++++++++++++++++ src/firetower/settings.py | 2 + .../slack_app/handlers/postmortem_meeting.py | 24 +++++- .../tests/handlers/test_postmortem_meeting.py | 64 +++++++++++++++ 10 files changed, 305 insertions(+), 14 deletions(-) diff --git a/config.example.toml b/config.example.toml index 7d83997c..5d86aaeb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -70,8 +70,11 @@ triage_bot_prompt = "We've been alerted by {alert_url}, run incident triage for # postmortem_attendee_collection_days = 2 postmortem_scheduler_bot_user_id = "" # When the scheduler bot replies in the incident channel with a Google Meet link, Firetower saves it -# as the incident's postmortem meeting link. -postmortem_scheduler_bot_prompt = "Schedule the {incident_number} postmortem meeting with everyone who reacted to {prompt_url}, then reply with the Google Meet link." +# as the incident's postmortem meeting link. If the reply is in the prompt's thread and includes an +# ISO 8601 start time with a UTC offset, Firetower also pings the bot again with +# postmortem_final_call_prompt the day before the meeting. +postmortem_scheduler_bot_prompt = "Schedule the {incident_number} postmortem meeting with everyone who reacted to {prompt_url}, then reply in this thread with the Google Meet link and the meeting start time in ISO 8601 UTC (e.g. 2026-11-03T17:00:00Z)." +postmortem_final_call_prompt = "The {incident_number} postmortem meeting ({meeting_url}) is tomorrow. Invite anyone who has reacted to {prompt_url} and isn't on the invite yet." [auth] iap_enabled = false diff --git a/src/firetower/config.py b/src/firetower/config.py index f29755ed..6c268f32 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -79,7 +79,12 @@ class SlackConfig: postmortem_scheduler_bot_user_id: str = "" postmortem_scheduler_bot_prompt: str = ( "Schedule the {incident_number} postmortem meeting with everyone who " - "reacted to {prompt_url}, then reply with the Google Meet link." + "reacted to {prompt_url}, then reply in this thread with the Google Meet " + "link and the meeting start time in ISO 8601 UTC (e.g. 2026-11-03T17:00:00Z)." + ) + postmortem_final_call_prompt: str = ( + "The {incident_number} postmortem meeting ({meeting_url}) is tomorrow. " + "Invite anyone who has reacted to {prompt_url} and isn't on the invite yet." ) diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 3d2185d3..5ee591bb 100644 --- a/src/firetower/incidents/hooks.py +++ b/src/firetower/incidents/hooks.py @@ -1454,6 +1454,34 @@ def record_postmortem_meeting(incident: Incident, meeting_url: str) -> None: ) +def schedule_postmortem_final_call( + incident: Incident, channel_id: str, message_ts: str, meeting_start: datetime +) -> None: + """Schedule one last scheduler bot ping the day before the postmortem meeting.""" + schedule_name = f"postmortem_final_call_{incident.id}" + run_at = meeting_start - timedelta(days=1) + if run_at <= timezone.now(): + Schedule.objects.filter(name=schedule_name).delete() + return + + Schedule.objects.update_or_create( + name=schedule_name, + defaults={ + "func": "firetower.incidents.tasks.send_postmortem_final_call", + "kwargs": json.dumps( + { + "incident_id": incident.id, + "channel_id": channel_id, + "message_ts": message_ts, + } + ), + "schedule_type": Schedule.ONCE, + "next_run": run_at, + "repeats": -1, + }, + ) + + def cancel_statuspage_followup_reminder(incident: Incident) -> None: Schedule.objects.filter(name=f"statuspage_followup_reminder_{incident.id}").delete() diff --git a/src/firetower/incidents/tasks/__init__.py b/src/firetower/incidents/tasks/__init__.py index 497d7460..17400a3b 100644 --- a/src/firetower/incidents/tasks/__init__.py +++ b/src/firetower/incidents/tasks/__init__.py @@ -5,7 +5,10 @@ from firetower.incidents.models import Incident from firetower.incidents.tasks.action_items import send_action_item_reminder from firetower.incidents.tasks.decorators import datadog_log -from firetower.incidents.tasks.postmortem import send_postmortem_attendee_followup +from firetower.incidents.tasks.postmortem import ( + send_postmortem_attendee_followup, + send_postmortem_final_call, +) from firetower.incidents.tasks.statuspage import ( STATUSPAGE_FOLLOWUP_REMINDER_MESSAGE, STATUSPAGE_REMINDER_MESSAGE, @@ -21,6 +24,7 @@ "schedule_demo", "send_action_item_reminder", "send_postmortem_attendee_followup", + "send_postmortem_final_call", "send_statuspage_followup_reminder", "send_statuspage_reminder", ] diff --git a/src/firetower/incidents/tasks/postmortem.py b/src/firetower/incidents/tasks/postmortem.py index c262b7c8..cf6be72a 100644 --- a/src/firetower/incidents/tasks/postmortem.py +++ b/src/firetower/incidents/tasks/postmortem.py @@ -4,19 +4,26 @@ from django.conf import settings from firetower.incidents.hooks import get_postmortem_scheduler_bot_user_id -from firetower.incidents.models import Incident, IncidentStatus +from firetower.incidents.models import ( + ExternalLink, + ExternalLinkType, + Incident, + IncidentStatus, +) from firetower.incidents.tasks.decorators import datadog_log from firetower.integrations.services.slack import SlackService logger = logging.getLogger(__name__) -@datadog_log -def send_postmortem_attendee_followup( - incident_id: int, channel_id: str, message_ts: str +def _prompt_scheduler_bot( + incident_id: int, + channel_id: str, + message_ts: str, + prompt_setting: str, ) -> None: - """Close the reaction window by posting the scheduler bot prompt for the reactors.""" - prompt = settings.SLACK.get("POSTMORTEM_SCHEDULER_BOT_PROMPT", "") + """Mention the scheduler bot in the attendee prompt's thread with a configured prompt.""" + prompt = settings.SLACK.get(prompt_setting, "") bot_user_id = get_postmortem_scheduler_bot_user_id() if not prompt or not bot_user_id: return @@ -24,9 +31,7 @@ def send_postmortem_attendee_followup( try: incident = Incident.objects.get(pk=incident_id) except Incident.DoesNotExist: - logger.warning( - f"Incident {incident_id} not found for postmortem attendee followup" - ) + logger.warning(f"Incident {incident_id} not found for {prompt_setting}") return if incident.is_private or incident.status not in ( @@ -35,14 +40,39 @@ def send_postmortem_attendee_followup( ): return + meeting_link = ExternalLink.objects.filter( + incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING_LINK + ).first() + slack = SlackService() text = f"<@{bot_user_id}> " + prompt.format_map( defaultdict( str, incident_number=incident.incident_number, prompt_url=slack.build_message_url(channel_id, message_ts), + meeting_url=meeting_link.url if meeting_link else "", ) ) slack.invite_to_channel(channel_id, [bot_user_id]) slack.post_message(channel_id, text, thread_ts=message_ts) + + +@datadog_log +def send_postmortem_attendee_followup( + incident_id: int, channel_id: str, message_ts: str +) -> None: + """Close the reaction window by posting the scheduler bot prompt for the reactors.""" + _prompt_scheduler_bot( + incident_id, channel_id, message_ts, "POSTMORTEM_SCHEDULER_BOT_PROMPT" + ) + + +@datadog_log +def send_postmortem_final_call( + incident_id: int, channel_id: str, message_ts: str +) -> None: + """Ask the scheduler bot to invite late reactors the day before the meeting.""" + _prompt_scheduler_bot( + incident_id, channel_id, message_ts, "POSTMORTEM_FINAL_CALL_PROMPT" + ) diff --git a/src/firetower/incidents/tests/test_hooks.py b/src/firetower/incidents/tests/test_hooks.py index 2c12c4a9..34a26306 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -32,6 +32,7 @@ page_for_channel, record_postmortem_meeting, resolve_pages_for_incident, + schedule_postmortem_final_call, schedule_statuspage_followup_reminder, start_postmortem_attendee_collection, ) @@ -4325,3 +4326,55 @@ def test_new_link_replaces_previous(self): incident=incident, type=ExternalLinkType.POSTMORTEM_MEETING_LINK ) assert link.url == new_url + + +@pytest.mark.django_db +class TestSchedulePostmortemFinalCall: + def _make_incident(self): + return Incident.objects.create( + title="Test Incident", + severity=IncidentSeverity.P1, + status=IncidentStatus.POSTMORTEM, + ) + + def test_schedules_day_before_meeting(self): + incident = self._make_incident() + meeting_start = timezone.now() + timedelta(days=5) + + schedule_postmortem_final_call(incident, "C12345", "1111.2222", meeting_start) + + schedule = Schedule.objects.get(name=f"postmortem_final_call_{incident.id}") + assert schedule.func == "firetower.incidents.tasks.send_postmortem_final_call" + assert schedule.schedule_type == Schedule.ONCE + assert schedule.next_run == meeting_start - timedelta(days=1) + assert ast.literal_eval(schedule.kwargs) == { + "incident_id": incident.id, + "channel_id": "C12345", + "message_ts": "1111.2222", + } + + def test_reschedule_replaces_previous(self): + incident = self._make_incident() + new_start = timezone.now() + timedelta(days=7) + + schedule_postmortem_final_call( + incident, "C12345", "1111.2222", timezone.now() + timedelta(days=5) + ) + schedule_postmortem_final_call(incident, "C12345", "1111.2222", new_start) + + schedule = Schedule.objects.get(name=f"postmortem_final_call_{incident.id}") + assert schedule.next_run == new_start - timedelta(days=1) + + def test_skips_and_clears_when_meeting_within_a_day(self): + incident = self._make_incident() + schedule_postmortem_final_call( + incident, "C12345", "1111.2222", timezone.now() + timedelta(days=5) + ) + + schedule_postmortem_final_call( + incident, "C12345", "1111.2222", timezone.now() + timedelta(hours=12) + ) + + assert not Schedule.objects.filter( + name=f"postmortem_final_call_{incident.id}" + ).exists() diff --git a/src/firetower/incidents/tests/test_tasks.py b/src/firetower/incidents/tests/test_tasks.py index 37961f87..3a2a2a82 100644 --- a/src/firetower/incidents/tests/test_tasks.py +++ b/src/firetower/incidents/tests/test_tasks.py @@ -24,6 +24,7 @@ schedule_demo, send_action_item_reminder, send_postmortem_attendee_followup, + send_postmortem_final_call, send_statuspage_followup_reminder, send_statuspage_reminder, ) @@ -1332,3 +1333,82 @@ def test_skips_when_incident_left_postmortem(self, status): mock_slack = self._run(incident) mock_slack.post_message.assert_not_called() + + +@pytest.mark.django_db +class TestSendPostmortemFinalCall: + MEET_URL = "https://meet.google.com/abc-defg-hij" + + @pytest.fixture(autouse=True) + def _configure_scheduler_bot(self): + slack_settings = { + **settings.SLACK, + "POSTMORTEM_SCHEDULER_BOT_USER_ID": "U_SCHEDULER", + "POSTMORTEM_FINAL_CALL_PROMPT": ( + "Final call {incident_number} {meeting_url} {prompt_url}" + ), + } + with patch.object(settings, "SLACK", slack_settings): + yield + + def _make_incident(self, **kwargs): + defaults = { + "title": "Test Incident", + "status": IncidentStatus.POSTMORTEM, + "severity": IncidentSeverity.P1, + } + defaults.update(kwargs) + incident = Incident.objects.create(**defaults) + ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.POSTMORTEM_MEETING_LINK, + url=self.MEET_URL, + ) + return incident + + def _run(self, incident): + mock_slack = MagicMock() + mock_slack.build_message_url.return_value = ( + "https://sentry.slack.com/archives/C12345/p11112222" + ) + with patch( + "firetower.incidents.tasks.postmortem.SlackService", + return_value=mock_slack, + ): + send_postmortem_final_call(incident.id, "C12345", "1111.2222") + return mock_slack + + def test_mentions_scheduler_bot_in_thread(self): + incident = self._make_incident() + + mock_slack = self._run(incident) + + mock_slack.invite_to_channel.assert_called_once_with("C12345", ["U_SCHEDULER"]) + mock_slack.post_message.assert_called_once_with( + "C12345", + f"<@U_SCHEDULER> Final call {incident.incident_number} {self.MEET_URL} " + "https://sentry.slack.com/archives/C12345/p11112222", + thread_ts="1111.2222", + ) + + def test_skips_when_prompt_empty(self): + incident = self._make_incident() + settings.SLACK["POSTMORTEM_FINAL_CALL_PROMPT"] = "" + + mock_slack = self._run(incident) + + mock_slack.post_message.assert_not_called() + + def test_skips_private_incident(self): + incident = self._make_incident(is_private=True) + + mock_slack = self._run(incident) + + mock_slack.post_message.assert_not_called() + + def test_skips_when_incident_canceled(self): + incident = self._make_incident(status=IncidentStatus.CANCELED) + + mock_slack = self._run(incident) + + mock_slack.post_message.assert_not_called() diff --git a/src/firetower/settings.py b/src/firetower/settings.py index e6e5b5e9..f5608449 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -265,6 +265,7 @@ class SlackSettings(TypedDict): POSTMORTEM_ATTENDEE_COLLECTION_DAYS: int | None POSTMORTEM_SCHEDULER_BOT_USER_ID: str POSTMORTEM_SCHEDULER_BOT_PROMPT: str + POSTMORTEM_FINAL_CALL_PROMPT: str SLACK: SlackSettings = { @@ -280,6 +281,7 @@ class SlackSettings(TypedDict): "POSTMORTEM_ATTENDEE_COLLECTION_DAYS": config.slack.postmortem_attendee_collection_days, "POSTMORTEM_SCHEDULER_BOT_USER_ID": config.slack.postmortem_scheduler_bot_user_id, "POSTMORTEM_SCHEDULER_BOT_PROMPT": config.slack.postmortem_scheduler_bot_prompt, + "POSTMORTEM_FINAL_CALL_PROMPT": config.slack.postmortem_final_call_prompt, } PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds) diff --git a/src/firetower/slack_app/handlers/postmortem_meeting.py b/src/firetower/slack_app/handlers/postmortem_meeting.py index 69baa40d..0671892e 100644 --- a/src/firetower/slack_app/handlers/postmortem_meeting.py +++ b/src/firetower/slack_app/handlers/postmortem_meeting.py @@ -1,9 +1,11 @@ import logging import re +from datetime import datetime from firetower.incidents.hooks import ( get_postmortem_scheduler_bot_user_id, record_postmortem_meeting, + schedule_postmortem_final_call, ) from firetower.incidents.models import IncidentStatus from firetower.slack_app.handlers.utils import get_incident_from_channel @@ -11,6 +13,9 @@ logger = logging.getLogger(__name__) MEET_URL_PATTERN = re.compile(r"https://meet\.google\.com/[a-z]+-[a-z]+-[a-z]+") +MEETING_START_PATTERN = re.compile( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})" +) def is_postmortem_meeting_message(event: dict) -> bool: @@ -23,9 +28,21 @@ def is_postmortem_meeting_message(event: dict) -> bool: ) +def parse_meeting_start(text: str) -> datetime | None: + """Return the first timezone-aware ISO 8601 timestamp in the text, if any.""" + match = MEETING_START_PATTERN.search(text) + if not match: + return None + try: + return datetime.fromisoformat(match.group(0)) + except ValueError: + return None + + def handle_postmortem_meeting_message(event: dict) -> None: """Record the meeting link the scheduler bot posted for a postmortem incident.""" - match = MEET_URL_PATTERN.search(event.get("text", "")) + text = event.get("text", "") + match = MEET_URL_PATTERN.search(text) channel_id = event.get("channel") if not match or not channel_id: return @@ -41,3 +58,8 @@ def handle_postmortem_meeting_message(event: dict) -> None: "Recording postmortem meeting link for incident %s", incident.incident_number ) record_postmortem_meeting(incident, match.group(0)) + + thread_ts = event.get("thread_ts") + meeting_start = parse_meeting_start(text) + if thread_ts and meeting_start: + schedule_postmortem_final_call(incident, channel_id, thread_ts, meeting_start) diff --git a/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py b/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py index c6bd0bad..afe364c5 100644 --- a/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py +++ b/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py @@ -1,3 +1,4 @@ +from datetime import UTC, datetime from unittest.mock import patch import pytest @@ -7,6 +8,7 @@ from firetower.slack_app.handlers.postmortem_meeting import ( handle_postmortem_meeting_message, is_postmortem_meeting_message, + parse_meeting_start, ) from .conftest import CHANNEL_ID @@ -66,3 +68,65 @@ def test_ignores_active_incident(self, incident): assert not incident.external_links.filter( type=ExternalLinkType.POSTMORTEM_MEETING_LINK ).exists() + + @patch( + "firetower.slack_app.handlers.postmortem_meeting.schedule_postmortem_final_call" + ) + def test_schedules_final_call_from_threaded_reply(self, mock_schedule, incident): + incident.status = IncidentStatus.POSTMORTEM + incident.save() + event = { + **_event(text=f"Scheduled for 2026-11-03T17:00:00Z: <{MEET_URL}|Join>"), + "thread_ts": "1111.2222", + } + + handle_postmortem_meeting_message(event) + + mock_schedule.assert_called_once_with( + incident, + CHANNEL_ID, + "1111.2222", + datetime(2026, 11, 3, 17, 0, tzinfo=UTC), + ) + + @patch( + "firetower.slack_app.handlers.postmortem_meeting.schedule_postmortem_final_call" + ) + def test_skips_final_call_without_start_time(self, mock_schedule, incident): + incident.status = IncidentStatus.POSTMORTEM + incident.save() + + handle_postmortem_meeting_message({**_event(), "thread_ts": "1111.2222"}) + + mock_schedule.assert_not_called() + + @patch( + "firetower.slack_app.handlers.postmortem_meeting.schedule_postmortem_final_call" + ) + def test_skips_final_call_outside_thread(self, mock_schedule, incident): + incident.status = IncidentStatus.POSTMORTEM + incident.save() + + handle_postmortem_meeting_message( + _event(text=f"2026-11-03T17:00:00Z <{MEET_URL}|Join>") + ) + + mock_schedule.assert_not_called() + + +class TestParseMeetingStart: + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("at 2026-11-03T17:00:00Z", datetime(2026, 11, 3, 17, 0, tzinfo=UTC)), + ("at 2026-11-03T17:00Z", datetime(2026, 11, 3, 17, 0, tzinfo=UTC)), + ( + "at 2026-11-03T09:00:00-08:00", + datetime(2026, 11, 3, 17, 0, tzinfo=UTC), + ), + ("at 2026-11-03T17:00:00", None), + ("next Tuesday", None), + ], + ) + def test_parse(self, text, expected): + assert parse_meeting_start(text) == expected