Skip to content
Open
8 changes: 8 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ 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.
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
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/routes/$incidentId/components/LinksList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 6 additions & 0 deletions src/firetower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_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 with the Google Meet link."
)


@deserialize
Expand Down
79 changes: 79 additions & 0 deletions src/firetower/incidents/hooks.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
from collections import defaultdict
from dataclasses import dataclass
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1387,6 +1406,54 @@ 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 cancel_statuspage_followup_reminder(incident: Incident) -> None:
Schedule.objects.filter(name=f"statuspage_followup_reminder_{incident.id}").delete()

Expand Down Expand Up @@ -1873,6 +1940,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-
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
]
6 changes: 5 additions & 1 deletion src/firetower/incidents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/firetower/incidents/tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +20,7 @@
"datadog_log",
"schedule_demo",
"send_action_item_reminder",
"send_postmortem_attendee_followup",
"send_statuspage_followup_reminder",
"send_statuspage_reminder",
]
Expand Down
48 changes: 48 additions & 0 deletions src/firetower/incidents/tasks/postmortem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 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", "")
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 postmortem attendee followup"
)
return

if incident.is_private or incident.status not in (
IncidentStatus.POSTMORTEM,
IncidentStatus.DONE,
):
return

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),
)
)

slack.invite_to_channel(channel_id, [bot_user_id])
slack.post_message(channel_id, text, thread_ts=message_ts)
Loading
Loading