diff --git a/config.example.toml b/config.example.toml index f1575ef9..5d86aaeb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -64,6 +64,17 @@ 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 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. 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/frontend/src/routes/$incidentId/components/LinksList.tsx b/frontend/src/routes/$incidentId/components/LinksList.tsx index ee98f4f1..dcbb51e5 100644 --- a/frontend/src/routes/$incidentId/components/LinksList.tsx +++ b/frontend/src/routes/$incidentId/components/LinksList.tsx @@ -29,8 +29,12 @@ const linkConfigs = { label: 'PagerDuty', icon: '/pagerduty.svg', }, - meeting_recording: { - label: 'Meeting Recording', + postmortem_meeting_link: { + label: 'Postmortem meeting', + icon: '/meeting_recording.svg', + }, + postmortem_meeting_recording: { + label: 'Postmortem meeting recording', icon: '/meeting_recording.svg', }, statuspage: { diff --git a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts index 11be3985..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(), - meeting_recording: z.string().optional(), + postmortem_meeting_recording: z.string().optional(), + postmortem_meeting_link: z.string().optional(), }); const IncidentDetailSchema = z.object({ diff --git a/src/firetower/config.py b/src/firetower/config.py index 38ff8342..6c268f32 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -75,6 +75,17 @@ class SlackConfig: triage_bot_prompt: str = ( "We've been alerted by {alert_url}, run incident triage for this issue." ) + 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 " + "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." + ) @deserialize diff --git a/src/firetower/incidents/hooks.py b/src/firetower/incidents/hooks.py index 3600e56e..5ee591bb 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,24 @@ 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 " + "*{days} days*." +) + + +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 + + +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) @@ -1387,6 +1406,82 @@ 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.is_private or incident.severity not in HIGH_SEVERITIES: + return + + 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, days=days + ), + ) + 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(days=days), + "repeats": -1, + }, + ) + + +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_LINK, + defaults={"url": meeting_url}, + ) + + +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() @@ -1873,6 +1968,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/migrations/0026_postmortem_meeting_link_types.py b/src/firetower/incidents/migrations/0026_postmortem_meeting_link_types.py new file mode 100644 index 00000000..4d872444 --- /dev/null +++ b/src/firetower/incidents/migrations/0026_postmortem_meeting_link_types.py @@ -0,0 +1,50 @@ +# 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_RECORDING" + ) + + +def restore_meeting_recording(apps, schema_editor): + ExternalLink = apps.get_model("incidents", "ExternalLink") + ExternalLink.objects.filter(type="POSTMORTEM_MEETING_RECORDING").update( + type="MEETING_RECORDING" + ) + + +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"), + ( + "POSTMORTEM_MEETING_RECORDING", + "Postmortem Meeting Recording", + ), + ("POSTMORTEM_MEETING_LINK", "Postmortem Meeting Link"), + ], + 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 4df575da..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" - MEETING_RECORDING = "MEETING_RECORDING", "Meeting Recording" + 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/tasks/__init__.py b/src/firetower/incidents/tasks/__init__.py index 3e562280..17400a3b 100644 --- a/src/firetower/incidents/tasks/__init__.py +++ b/src/firetower/incidents/tasks/__init__.py @@ -5,6 +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, + send_postmortem_final_call, +) from firetower.incidents.tasks.statuspage import ( STATUSPAGE_FOLLOWUP_REMINDER_MESSAGE, STATUSPAGE_REMINDER_MESSAGE, @@ -19,6 +23,8 @@ "datadog_log", "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 new file mode 100644 index 00000000..cf6be72a --- /dev/null +++ b/src/firetower/incidents/tasks/postmortem.py @@ -0,0 +1,78 @@ +import logging +from collections import defaultdict + +from django.conf import settings + +from firetower.incidents.hooks import get_postmortem_scheduler_bot_user_id +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__) + + +def _prompt_scheduler_bot( + incident_id: int, + channel_id: str, + message_ts: str, + prompt_setting: str, +) -> None: + """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 + + try: + incident = Incident.objects.get(pk=incident_id) + except Incident.DoesNotExist: + logger.warning(f"Incident {incident_id} not found for {prompt_setting}") + return + + if incident.is_private or incident.status not in ( + IncidentStatus.POSTMORTEM, + IncidentStatus.DONE, + ): + 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 8e4dd7af..34a26306 100644 --- a/src/firetower/incidents/tests/test_hooks.py +++ b/src/firetower/incidents/tests/test_hooks.py @@ -30,8 +30,11 @@ on_title_changed, on_visibility_changed, page_for_channel, + record_postmortem_meeting, resolve_pages_for_incident, + schedule_postmortem_final_call, schedule_statuspage_followup_reminder, + start_postmortem_attendee_collection, ) from firetower.incidents.models import ( ExternalLink, @@ -4142,3 +4145,236 @@ 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_DAYS": 2, + "POSTMORTEM_SCHEDULER_BOT_USER_ID": "U_SCHEDULER", + } + + 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 "2 days" 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(days=2) + + @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_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 = { + **settings.SLACK, + "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() + + 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() + + +@pytest.mark.django_db +class TestRecordPostmortemMeeting: + MEET_URL = "https://meet.google.com/abc-defg-hij" + + 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_LINK + ) + assert link.url == self.MEET_URL + + def test_new_link_replaces_previous(self): + incident = self._make_incident() + 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_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 f9f53ed2..3a2a2a82 100644 --- a/src/firetower/incidents/tests/test_tasks.py +++ b/src/firetower/incidents/tests/test_tasks.py @@ -23,6 +23,8 @@ datadog_log, schedule_demo, send_action_item_reminder, + send_postmortem_attendee_followup, + send_postmortem_final_call, send_statuspage_followup_reminder, send_statuspage_reminder, ) @@ -1236,3 +1238,177 @@ 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", + thread_ts="1111.2222", + ) + + 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_skips_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) + + mock_slack.invite_to_channel.assert_not_called() + mock_slack.post_message.assert_not_called() + + 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() + + 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) + + 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/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index eebb0d7a..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: @@ -466,6 +473,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..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): @@ -551,6 +562,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..f5608449 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -262,6 +262,10 @@ class SlackSettings(TypedDict): SLASH_COMMAND: str TRIAGE_BOT_USER_ID: str TRIAGE_BOT_PROMPT: str + POSTMORTEM_ATTENDEE_COLLECTION_DAYS: int | None + POSTMORTEM_SCHEDULER_BOT_USER_ID: str + POSTMORTEM_SCHEDULER_BOT_PROMPT: str + POSTMORTEM_FINAL_CALL_PROMPT: str SLACK: SlackSettings = { @@ -274,6 +278,10 @@ 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_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/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..0671892e --- /dev/null +++ b/src/firetower/slack_app/handlers/postmortem_meeting.py @@ -0,0 +1,65 @@ +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 + +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: + """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 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.""" + text = event.get("text", "") + match = MEET_URL_PATTERN.search(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)) + + 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 new file mode 100644 index 00000000..afe364c5 --- /dev/null +++ b/src/firetower/slack_app/tests/handlers/test_postmortem_meeting.py @@ -0,0 +1,132 @@ +from datetime import UTC, datetime +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, + parse_meeting_start, +) + +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_LINK + ).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_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