firefighter-incident 0.0.25__py3-none-any.whl → 0.0.27__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- firefighter/_version.py +2 -2
- firefighter/confluence/models.py +16 -1
- firefighter/incidents/management/__init__.py +1 -0
- firefighter/incidents/management/commands/__init__.py +1 -0
- firefighter/incidents/management/commands/backdate_incident_mitigated.py +94 -0
- firefighter/incidents/management/commands/test_postmortem_reminders.py +113 -0
- firefighter/incidents/migrations/0030_add_mitigated_at_field.py +22 -0
- firefighter/incidents/models/incident.py +43 -8
- firefighter/jira_app/service_postmortem.py +18 -4
- firefighter/jira_app/signals/postmortem_created.py +108 -46
- firefighter/slack/messages/slack_messages.py +162 -0
- firefighter/slack/migrations/0009_add_postmortem_reminder_periodic_task.py +60 -0
- firefighter/slack/rules.py +22 -0
- firefighter/slack/tasks/send_postmortem_reminders.py +127 -0
- firefighter/slack/views/modals/close.py +113 -3
- firefighter/slack/views/modals/closure_reason.py +54 -25
- firefighter/slack/views/modals/update_status.py +4 -4
- {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/RECORD +30 -23
- firefighter_tests/test_incidents/test_incident_urls.py +4 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +109 -1
- firefighter_tests/test_incidents/test_views/test_incident_detail_view.py +4 -0
- firefighter_tests/test_jira_app/test_postmortem_service.py +2 -2
- firefighter_tests/test_slack/messages/test_slack_messages.py +4 -0
- firefighter_tests/test_slack/views/modals/test_close.py +4 -0
- firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +109 -26
- firefighter_tests/test_slack/views/modals/test_update_status.py +45 -51
- {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/licenses/LICENSE +0 -0
|
@@ -615,6 +615,168 @@ class SlackMessageIncidentPostMortemCreated(SlackMessageSurface):
|
|
|
615
615
|
return [SectionBlock(text=self.get_text())]
|
|
616
616
|
|
|
617
617
|
|
|
618
|
+
class SlackMessageIncidentPostMortemCreatedAnnouncement(SlackMessageSurface):
|
|
619
|
+
"""Message to announce post-mortem creation in #critical-incidents (tech_incidents tag)."""
|
|
620
|
+
|
|
621
|
+
id = "ff_incident_postmortem_created_announcement"
|
|
622
|
+
incident: Incident
|
|
623
|
+
|
|
624
|
+
def __init__(self, incident: Incident) -> None:
|
|
625
|
+
self.incident = incident
|
|
626
|
+
super().__init__()
|
|
627
|
+
|
|
628
|
+
def get_text(self) -> str:
|
|
629
|
+
"""Generate announcement text for critical incidents channel."""
|
|
630
|
+
return f"📔 Post-mortem created for {self.incident.priority} incident #{self.incident.id}: {self.incident.title}"
|
|
631
|
+
|
|
632
|
+
def get_blocks(self) -> list[Block]:
|
|
633
|
+
fields = [
|
|
634
|
+
f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
|
|
635
|
+
f":calendar: *Created at:* {date_time(self.incident.created_at)}",
|
|
636
|
+
f":slack: *Channel:* <#{self.incident.conversation.channel_id}>",
|
|
637
|
+
]
|
|
638
|
+
|
|
639
|
+
# Add Confluence link if available
|
|
640
|
+
if hasattr(self.incident, "postmortem_for"):
|
|
641
|
+
fields.append(
|
|
642
|
+
f":confluence: <{self.incident.postmortem_for.page_url}|*Confluence Post-Mortem*>"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Add Jira link if available
|
|
646
|
+
if hasattr(self.incident, "jira_postmortem_for"):
|
|
647
|
+
jira_pm = self.incident.jira_postmortem_for
|
|
648
|
+
fields.append(
|
|
649
|
+
f":jira_new: <{jira_pm.issue_url}|*Jira Post-Mortem ({jira_pm.jira_issue_key})*>"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
blocks: list[Block] = [
|
|
653
|
+
SectionBlock(
|
|
654
|
+
text=f"📔 *Post-mortem created for incident #{self.incident.id}*"
|
|
655
|
+
),
|
|
656
|
+
SectionBlock(text=f"*{shorten(self.incident.title, 2995, placeholder='...')}*"),
|
|
657
|
+
DividerBlock(),
|
|
658
|
+
SectionBlock(fields=fields),
|
|
659
|
+
]
|
|
660
|
+
|
|
661
|
+
return blocks
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class SlackMessagePostMortemReminder5Days(SlackMessageSurface):
|
|
665
|
+
"""Reminder message sent 5 days after incident reaches MITIGATED status.
|
|
666
|
+
|
|
667
|
+
Sent to both the incident channel and #critical-incidents (tech_incidents tag).
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
id = "ff_incident_postmortem_reminder_5days"
|
|
671
|
+
incident: Incident
|
|
672
|
+
|
|
673
|
+
def __init__(self, incident: Incident) -> None:
|
|
674
|
+
self.incident = incident
|
|
675
|
+
super().__init__()
|
|
676
|
+
|
|
677
|
+
def get_text(self) -> str:
|
|
678
|
+
return f"⏰ Reminder: Post-mortem for incident #{self.incident.id} needs to be completed"
|
|
679
|
+
|
|
680
|
+
def get_blocks(self) -> list[Block]:
|
|
681
|
+
blocks: list[Block] = [
|
|
682
|
+
HeaderBlock(
|
|
683
|
+
text=PlainTextObject(
|
|
684
|
+
text="⏰ Post-mortem Reminder ⏰",
|
|
685
|
+
emoji=True,
|
|
686
|
+
)
|
|
687
|
+
),
|
|
688
|
+
SectionBlock(
|
|
689
|
+
text="This incident was mitigated 5 days ago. The post-mortem *must* be completed to close the incident."
|
|
690
|
+
),
|
|
691
|
+
DividerBlock(),
|
|
692
|
+
]
|
|
693
|
+
|
|
694
|
+
# Add links to post-mortems
|
|
695
|
+
pm_links = []
|
|
696
|
+
if hasattr(self.incident, "postmortem_for"):
|
|
697
|
+
pm_links.append(
|
|
698
|
+
ButtonElement(
|
|
699
|
+
text="Open Confluence Post-Mortem",
|
|
700
|
+
url=self.incident.postmortem_for.page_edit_url,
|
|
701
|
+
action_id="open_link",
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
if hasattr(self.incident, "jira_postmortem_for"):
|
|
706
|
+
jira_pm = self.incident.jira_postmortem_for
|
|
707
|
+
pm_links.append(
|
|
708
|
+
ButtonElement(
|
|
709
|
+
text=f"Open Jira Post-Mortem ({jira_pm.jira_issue_key})",
|
|
710
|
+
url=jira_pm.issue_url,
|
|
711
|
+
action_id="open_link",
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
if pm_links:
|
|
716
|
+
blocks.append(ActionsBlock(elements=pm_links))
|
|
717
|
+
|
|
718
|
+
# Add action buttons
|
|
719
|
+
blocks.extend([
|
|
720
|
+
DividerBlock(),
|
|
721
|
+
SectionBlock(
|
|
722
|
+
text="Update the incident status or close it once the post-mortem is complete.",
|
|
723
|
+
accessory=ButtonElement(
|
|
724
|
+
text="Update status",
|
|
725
|
+
value=str(self.incident.id),
|
|
726
|
+
action_id=UpdateStatusModal.open_action,
|
|
727
|
+
),
|
|
728
|
+
),
|
|
729
|
+
])
|
|
730
|
+
|
|
731
|
+
return blocks
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class SlackMessagePostMortemReminder5DaysAnnouncement(SlackMessageSurface):
|
|
735
|
+
"""Announcement version of 5-day reminder for #critical-incidents channel."""
|
|
736
|
+
|
|
737
|
+
id = "ff_incident_postmortem_reminder_5days_announcement"
|
|
738
|
+
incident: Incident
|
|
739
|
+
|
|
740
|
+
def __init__(self, incident: Incident) -> None:
|
|
741
|
+
self.incident = incident
|
|
742
|
+
super().__init__()
|
|
743
|
+
|
|
744
|
+
def get_text(self) -> str:
|
|
745
|
+
return f"⏰ Post-mortem reminder for {self.incident.priority} incident #{self.incident.id}: {self.incident.title}"
|
|
746
|
+
|
|
747
|
+
def get_blocks(self) -> list[Block]:
|
|
748
|
+
fields = [
|
|
749
|
+
f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
|
|
750
|
+
f":calendar: *Mitigated:* {date_time(self.incident.mitigated_at) if self.incident.mitigated_at else 'Unknown'}",
|
|
751
|
+
f":slack: *Channel:* <#{self.incident.conversation.channel_id}>",
|
|
752
|
+
]
|
|
753
|
+
|
|
754
|
+
# Add post-mortem links
|
|
755
|
+
if hasattr(self.incident, "postmortem_for"):
|
|
756
|
+
fields.append(
|
|
757
|
+
f":confluence: <{self.incident.postmortem_for.page_url}|*Confluence Post-Mortem*>"
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
if hasattr(self.incident, "jira_postmortem_for"):
|
|
761
|
+
jira_pm = self.incident.jira_postmortem_for
|
|
762
|
+
fields.append(
|
|
763
|
+
f":jira_new: <{jira_pm.issue_url}|*Jira Post-Mortem ({jira_pm.jira_issue_key})*>"
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
blocks: list[Block] = [
|
|
767
|
+
SectionBlock(
|
|
768
|
+
text=f"⏰ *Post-mortem reminder for incident #{self.incident.id}*"
|
|
769
|
+
),
|
|
770
|
+
SectionBlock(
|
|
771
|
+
text=f"*{shorten(self.incident.title, 2995, placeholder='...')}*\n_This incident was mitigated 5 days ago. Post-mortem completion is overdue._"
|
|
772
|
+
),
|
|
773
|
+
DividerBlock(),
|
|
774
|
+
SectionBlock(fields=fields),
|
|
775
|
+
]
|
|
776
|
+
|
|
777
|
+
return blocks
|
|
778
|
+
|
|
779
|
+
|
|
618
780
|
class SlackMessageIncidentDuringOffHours(SlackMessageSurface):
|
|
619
781
|
id = "ff_incident_during_off_hours"
|
|
620
782
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Generated by Django 4.2.21 on 2025-12-19 14:05
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_postmortem_reminder_task(apps, schema_editor):
|
|
7
|
+
"""Create periodic task for post-mortem reminders.
|
|
8
|
+
|
|
9
|
+
Runs twice daily at 10:00 AM and 3:00 PM to check for incidents mitigated 5+ days ago.
|
|
10
|
+
"""
|
|
11
|
+
CrontabSchedule = apps.get_model("django_celery_beat", "CrontabSchedule")
|
|
12
|
+
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
|
13
|
+
|
|
14
|
+
# Create crontab schedule for 10:00 AM and 3:00 PM (Europe/Paris timezone)
|
|
15
|
+
# Crontab format: minute hour day_of_week day_of_month month_of_year
|
|
16
|
+
# hour='10,15' means run at both 10:00 and 15:00
|
|
17
|
+
# Using Europe/Paris timezone handles DST automatically
|
|
18
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
19
|
+
minute="0",
|
|
20
|
+
hour="10,15",
|
|
21
|
+
day_of_week="*",
|
|
22
|
+
day_of_month="*",
|
|
23
|
+
month_of_year="*",
|
|
24
|
+
timezone="Europe/Paris",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Create the periodic task
|
|
28
|
+
PeriodicTask.objects.get_or_create(
|
|
29
|
+
name="Send post-mortem reminders for mitigated incidents",
|
|
30
|
+
defaults={
|
|
31
|
+
"task": "slack.send_postmortem_reminders",
|
|
32
|
+
"crontab": schedule,
|
|
33
|
+
"enabled": True,
|
|
34
|
+
"description": "Send reminders for incidents mitigated 5+ days ago that still need post-mortem completion (runs at 10 AM and 3 PM)",
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def remove_postmortem_reminder_task(apps, schema_editor):
|
|
40
|
+
"""Remove the periodic task on migration rollback."""
|
|
41
|
+
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
|
42
|
+
|
|
43
|
+
PeriodicTask.objects.filter(
|
|
44
|
+
task="slack.send_postmortem_reminders"
|
|
45
|
+
).delete()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Migration(migrations.Migration):
|
|
49
|
+
|
|
50
|
+
dependencies = [
|
|
51
|
+
("slack", "0008_alter_conversation_incident_categories_and_more"),
|
|
52
|
+
("django_celery_beat", "0019_alter_periodictasks_options"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
operations = [
|
|
56
|
+
migrations.RunPython(
|
|
57
|
+
create_postmortem_reminder_task,
|
|
58
|
+
reverse_code=remove_postmortem_reminder_task,
|
|
59
|
+
),
|
|
60
|
+
]
|
firefighter/slack/rules.py
CHANGED
|
@@ -52,3 +52,25 @@ def should_publish_in_it_deploy_channel(incident: Incident) -> bool:
|
|
|
52
52
|
and not incident.private
|
|
53
53
|
and incident.incident_category.deploy_warning
|
|
54
54
|
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def should_publish_pm_in_general_channel(incident: Incident) -> bool:
|
|
58
|
+
"""Determine if post-mortem creation should be announced in #critical-incidents.
|
|
59
|
+
|
|
60
|
+
Post-mortems are announced for P1-P3 production incidents that are not private
|
|
61
|
+
and require a post-mortem.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
incident: The incident for which a post-mortem was created.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if the post-mortem creation should be announced in tech_incidents channel.
|
|
68
|
+
"""
|
|
69
|
+
return (
|
|
70
|
+
incident.priority is not None
|
|
71
|
+
and incident.priority.value <= 3
|
|
72
|
+
and incident.environment is not None
|
|
73
|
+
and incident.environment.value == "PRD"
|
|
74
|
+
and not incident.private
|
|
75
|
+
and incident.needs_postmortem
|
|
76
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Celery task to send post-mortem reminders for incidents.
|
|
2
|
+
|
|
3
|
+
This task runs periodically to check for incidents that were mitigated
|
|
4
|
+
5 days ago and still need their post-mortem completed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from celery import shared_task
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.utils import timezone
|
|
16
|
+
|
|
17
|
+
from firefighter.incidents.enums import IncidentStatus
|
|
18
|
+
from firefighter.incidents.models.incident import Incident
|
|
19
|
+
from firefighter.slack.models.conversation import Conversation
|
|
20
|
+
from firefighter.slack.rules import should_publish_pm_in_general_channel
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from firefighter.slack.models import Message
|
|
24
|
+
|
|
25
|
+
if settings.ENABLE_SLACK:
|
|
26
|
+
from firefighter.slack.messages.slack_messages import (
|
|
27
|
+
SlackMessagePostMortemReminder5Days,
|
|
28
|
+
SlackMessagePostMortemReminder5DaysAnnouncement,
|
|
29
|
+
)
|
|
30
|
+
from firefighter.slack.models import Message
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Days after mitigation to send reminder
|
|
35
|
+
POSTMORTEM_REMINDER_DAYS = 5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@shared_task(name="slack.send_postmortem_reminders")
|
|
39
|
+
def send_postmortem_reminders() -> None:
|
|
40
|
+
"""Send post-mortem completion reminders for incidents mitigated 5+ days ago.
|
|
41
|
+
|
|
42
|
+
This task:
|
|
43
|
+
1. Finds incidents that were mitigated at least 5 days ago
|
|
44
|
+
2. Filters for incidents that still need their post-mortem completed
|
|
45
|
+
3. Sends reminders to both the incident channel and #critical-incidents
|
|
46
|
+
4. Tracks sent reminders to avoid duplicates
|
|
47
|
+
"""
|
|
48
|
+
# Calculate the cutoff date (5 days ago)
|
|
49
|
+
cutoff_date = timezone.now() - timedelta(days=POSTMORTEM_REMINDER_DAYS)
|
|
50
|
+
|
|
51
|
+
# Get incidents that:
|
|
52
|
+
# - Were mitigated at least 5 days ago
|
|
53
|
+
# - Still need post-mortem (P1-P3)
|
|
54
|
+
# - Are in MITIGATED or POST_MORTEM status (not yet closed)
|
|
55
|
+
# - Are not ignored
|
|
56
|
+
incidents_needing_reminder = Incident.objects.filter(
|
|
57
|
+
mitigated_at__lte=cutoff_date,
|
|
58
|
+
mitigated_at__isnull=False,
|
|
59
|
+
_status__in=[
|
|
60
|
+
IncidentStatus.MITIGATED.value,
|
|
61
|
+
IncidentStatus.POST_MORTEM.value,
|
|
62
|
+
],
|
|
63
|
+
priority__needs_postmortem=True,
|
|
64
|
+
ignore=False,
|
|
65
|
+
).select_related("conversation", "priority", "environment")
|
|
66
|
+
|
|
67
|
+
logger.info(
|
|
68
|
+
f"Found {incidents_needing_reminder.count()} incidents needing post-mortem reminders"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
for incident in incidents_needing_reminder:
|
|
72
|
+
# Check if we already sent a reminder for this incident
|
|
73
|
+
if Message.objects.filter(
|
|
74
|
+
ff_type=SlackMessagePostMortemReminder5Days.id,
|
|
75
|
+
incident=incident,
|
|
76
|
+
).exists():
|
|
77
|
+
logger.debug(
|
|
78
|
+
f"Skipping incident #{incident.id} - reminder already sent"
|
|
79
|
+
)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Skip if no conversation
|
|
83
|
+
if not hasattr(incident, "conversation") or not incident.conversation:
|
|
84
|
+
logger.warning(
|
|
85
|
+
f"Incident #{incident.id} has no conversation, skipping reminder"
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Send reminder to incident channel
|
|
90
|
+
try:
|
|
91
|
+
reminder_message = SlackMessagePostMortemReminder5Days(incident)
|
|
92
|
+
incident.conversation.send_message_and_save(reminder_message)
|
|
93
|
+
logger.info(
|
|
94
|
+
f"Sent post-mortem reminder to incident #{incident.id} channel"
|
|
95
|
+
)
|
|
96
|
+
except Exception:
|
|
97
|
+
logger.exception(
|
|
98
|
+
f"Failed to send post-mortem reminder to incident #{incident.id} channel"
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Send announcement to #critical-incidents if applicable
|
|
103
|
+
if should_publish_pm_in_general_channel(incident):
|
|
104
|
+
try:
|
|
105
|
+
tech_incidents_conversation = Conversation.objects.get_or_none(
|
|
106
|
+
tag="tech_incidents"
|
|
107
|
+
)
|
|
108
|
+
if tech_incidents_conversation:
|
|
109
|
+
announcement = SlackMessagePostMortemReminder5DaysAnnouncement(
|
|
110
|
+
incident
|
|
111
|
+
)
|
|
112
|
+
tech_incidents_conversation.send_message_and_save(announcement)
|
|
113
|
+
logger.info(
|
|
114
|
+
f"Sent post-mortem reminder to tech_incidents for incident #{incident.id}"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
logger.warning(
|
|
118
|
+
"Could not find tech_incidents conversation! Is there a channel with tag tech_incidents?"
|
|
119
|
+
)
|
|
120
|
+
except Exception:
|
|
121
|
+
logger.exception(
|
|
122
|
+
f"Failed to send post-mortem reminder to tech_incidents for incident #{incident.id}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
logger.info(
|
|
126
|
+
f"Post-mortem reminder task completed. Processed {incidents_needing_reminder.count()} incidents."
|
|
127
|
+
)
|
|
@@ -104,7 +104,9 @@ class CloseModal(
|
|
|
104
104
|
]
|
|
105
105
|
),
|
|
106
106
|
]
|
|
107
|
-
elif reason[0] == "STATUS_NOT_POST_MORTEM"
|
|
107
|
+
elif reason[0] == "STATUS_NOT_POST_MORTEM" and getattr(
|
|
108
|
+
settings, "ENABLE_CONFLUENCE", False
|
|
109
|
+
):
|
|
108
110
|
reason_blocks += [
|
|
109
111
|
SectionBlock(
|
|
110
112
|
text=f":warning: *Status is not _{IncidentStatus.POST_MORTEM.label}_* :warning:\n"
|
|
@@ -148,6 +150,44 @@ class CloseModal(
|
|
|
148
150
|
]
|
|
149
151
|
),
|
|
150
152
|
]
|
|
153
|
+
elif reason[0] == "JIRA_POSTMORTEM_NOT_READY" and getattr(
|
|
154
|
+
settings, "ENABLE_JIRA", False
|
|
155
|
+
):
|
|
156
|
+
reason_blocks += [
|
|
157
|
+
SectionBlock(
|
|
158
|
+
text=":warning: *Jira post-mortem not ready* :warning:\n"
|
|
159
|
+
),
|
|
160
|
+
ContextBlock(
|
|
161
|
+
elements=[
|
|
162
|
+
MarkdownTextObject(
|
|
163
|
+
text=f"{reason[1]}\nPlease move the Jira post-mortem to the Ready status before closing the incident."
|
|
164
|
+
)
|
|
165
|
+
]
|
|
166
|
+
),
|
|
167
|
+
ActionsBlock(
|
|
168
|
+
elements=[
|
|
169
|
+
ButtonElement(
|
|
170
|
+
text="Open Jira post-mortem",
|
|
171
|
+
action_id="open_link",
|
|
172
|
+
url=incident.jira_postmortem_for.issue_url,
|
|
173
|
+
style="primary",
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
),
|
|
177
|
+
]
|
|
178
|
+
elif reason[0] == "JIRA_POSTMORTEM_STATUS_UNKNOWN":
|
|
179
|
+
reason_blocks += [
|
|
180
|
+
SectionBlock(
|
|
181
|
+
text=":warning: *Could not verify Jira post-mortem status* :warning:\n"
|
|
182
|
+
),
|
|
183
|
+
ContextBlock(
|
|
184
|
+
elements=[
|
|
185
|
+
MarkdownTextObject(
|
|
186
|
+
text=f"{reason[1]}\nPlease check the Jira post-mortem status before closing the incident."
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
),
|
|
190
|
+
]
|
|
151
191
|
elif reason[0] == "STATUS_NOT_MITIGATED":
|
|
152
192
|
reason_blocks += [
|
|
153
193
|
SectionBlock(
|
|
@@ -178,6 +218,74 @@ class CloseModal(
|
|
|
178
218
|
]
|
|
179
219
|
),
|
|
180
220
|
]
|
|
221
|
+
elif reason[0] == "POSTMORTEM_NOT_READY":
|
|
222
|
+
reason_blocks += [
|
|
223
|
+
SectionBlock(
|
|
224
|
+
text=":warning: *Post-mortem is not Ready* :warning:\n"
|
|
225
|
+
),
|
|
226
|
+
ContextBlock(
|
|
227
|
+
elements=[
|
|
228
|
+
MarkdownTextObject(
|
|
229
|
+
text="The linked Jira post-mortem must be in the *Ready* status before closing the incident.\n"
|
|
230
|
+
+ reason[1]
|
|
231
|
+
)
|
|
232
|
+
]
|
|
233
|
+
),
|
|
234
|
+
ActionsBlock(
|
|
235
|
+
elements=[
|
|
236
|
+
ButtonElement(
|
|
237
|
+
text="Open Jira post-mortem",
|
|
238
|
+
action_id="open_link",
|
|
239
|
+
url=(
|
|
240
|
+
getattr(
|
|
241
|
+
incident.jira_postmortem_for,
|
|
242
|
+
"issue_url",
|
|
243
|
+
None,
|
|
244
|
+
)
|
|
245
|
+
if hasattr(incident, "jira_postmortem_for")
|
|
246
|
+
else None
|
|
247
|
+
),
|
|
248
|
+
style="primary",
|
|
249
|
+
),
|
|
250
|
+
ButtonElement(
|
|
251
|
+
text="Update status",
|
|
252
|
+
action_id=UpdateStatusModal.push_action,
|
|
253
|
+
value=str(incident.id),
|
|
254
|
+
),
|
|
255
|
+
]
|
|
256
|
+
),
|
|
257
|
+
]
|
|
258
|
+
elif reason[0] == "POSTMORTEM_STATUS_UNKNOWN":
|
|
259
|
+
reason_blocks += [
|
|
260
|
+
SectionBlock(
|
|
261
|
+
text=":warning: *Could not verify post-mortem status* :warning:\n"
|
|
262
|
+
),
|
|
263
|
+
ContextBlock(
|
|
264
|
+
elements=[
|
|
265
|
+
MarkdownTextObject(
|
|
266
|
+
text="We could not verify the Jira post-mortem status. Please open it in Jira to confirm it is Ready before closing."
|
|
267
|
+
)
|
|
268
|
+
]
|
|
269
|
+
),
|
|
270
|
+
ActionsBlock(
|
|
271
|
+
elements=[
|
|
272
|
+
ButtonElement(
|
|
273
|
+
text="Open Jira post-mortem",
|
|
274
|
+
action_id="open_link",
|
|
275
|
+
url=(
|
|
276
|
+
getattr(
|
|
277
|
+
incident.jira_postmortem_for,
|
|
278
|
+
"issue_url",
|
|
279
|
+
None,
|
|
280
|
+
)
|
|
281
|
+
if hasattr(incident, "jira_postmortem_for")
|
|
282
|
+
else None
|
|
283
|
+
),
|
|
284
|
+
style="primary",
|
|
285
|
+
),
|
|
286
|
+
]
|
|
287
|
+
),
|
|
288
|
+
]
|
|
181
289
|
|
|
182
290
|
return View(
|
|
183
291
|
type="modal",
|
|
@@ -227,7 +335,7 @@ class CloseModal(
|
|
|
227
335
|
|
|
228
336
|
def handle_modal_fn( # type: ignore[override]
|
|
229
337
|
self, ack: Ack, body: dict[str, Any], incident: Incident, user: User
|
|
230
|
-
) ->
|
|
338
|
+
) -> bool | None:
|
|
231
339
|
"""Handle response from /incident close modal."""
|
|
232
340
|
# Check if this should be handled by closure reason modal
|
|
233
341
|
closure_result = handle_close_modal_callback(ack, body, incident, user)
|
|
@@ -243,7 +351,9 @@ class CloseModal(
|
|
|
243
351
|
update_kwargs = {}
|
|
244
352
|
for changed_key in form.changed_data:
|
|
245
353
|
if changed_key == "incident_category":
|
|
246
|
-
update_kwargs["incident_category_id"] = form.cleaned_data[
|
|
354
|
+
update_kwargs["incident_category_id"] = form.cleaned_data[
|
|
355
|
+
changed_key
|
|
356
|
+
].id
|
|
247
357
|
if changed_key in {"description", "title", "message"}:
|
|
248
358
|
update_kwargs[changed_key] = form.cleaned_data[changed_key]
|
|
249
359
|
# Check can close
|
|
@@ -115,36 +115,63 @@ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
|
|
|
115
115
|
self, ack: Ack, body: dict[str, Any], incident: Incident, user: User
|
|
116
116
|
) -> bool | None:
|
|
117
117
|
"""Handle the closure reason modal submission."""
|
|
118
|
-
#
|
|
119
|
-
# This validation happens BEFORE ack() so we can display errors in the modal
|
|
120
|
-
can_close, reasons = incident.can_be_closed
|
|
121
|
-
if not can_close:
|
|
122
|
-
# Build error message from reasons
|
|
123
|
-
error_messages = [reason[1] for reason in reasons]
|
|
124
|
-
error_text = "\n".join([f"• {msg}" for msg in error_messages])
|
|
125
|
-
ack(
|
|
126
|
-
response_action="errors",
|
|
127
|
-
errors={
|
|
128
|
-
"closure_message": f"Cannot close this incident:\n{error_text}"
|
|
129
|
-
}
|
|
130
|
-
)
|
|
131
|
-
return False
|
|
132
|
-
|
|
133
|
-
# Clear ALL modals in the stack (not just this one)
|
|
134
|
-
# This ensures the underlying "Update Status" modal is also closed
|
|
135
|
-
ack(response_action="clear")
|
|
136
|
-
|
|
137
|
-
# Extract form values
|
|
118
|
+
# Extract form values up-front so validation can account for the submitted reason
|
|
138
119
|
state_values = body["view"]["state"]["values"]
|
|
139
120
|
closure_reason = state_values["closure_reason"]["select_closure_reason"][
|
|
140
121
|
"selected_option"
|
|
141
122
|
]["value"]
|
|
142
123
|
closure_reference = (
|
|
143
|
-
state_values["closure_reference"]["input_closure_reference"].get(
|
|
124
|
+
state_values["closure_reference"]["input_closure_reference"].get(
|
|
125
|
+
"value", ""
|
|
126
|
+
)
|
|
144
127
|
or ""
|
|
145
128
|
)
|
|
146
129
|
message = state_values["closure_message"]["input_closure_message"]["value"]
|
|
147
130
|
|
|
131
|
+
# For early closure (OPEN/INVESTIGATING), we bypass normal workflow checks
|
|
132
|
+
# For normal closure (MITIGATED/POST_MORTEM), we must validate key events
|
|
133
|
+
current_status = incident.status
|
|
134
|
+
is_early_closure = current_status.value in {IncidentStatus.OPEN, IncidentStatus.INVESTIGATING}
|
|
135
|
+
|
|
136
|
+
if not is_early_closure:
|
|
137
|
+
# Normal closure path - validate that incident can be closed
|
|
138
|
+
can_close, reasons = incident.can_be_closed
|
|
139
|
+
if not can_close:
|
|
140
|
+
# Build error message from reasons
|
|
141
|
+
error_messages = [reason[1] for reason in reasons]
|
|
142
|
+
error_text = "\n".join([f"• {msg}" for msg in error_messages])
|
|
143
|
+
ack(
|
|
144
|
+
response_action="errors",
|
|
145
|
+
errors={
|
|
146
|
+
"closure_message": f"Cannot close this incident:\n{error_text}"
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
return False
|
|
150
|
+
else:
|
|
151
|
+
# Early closure path - validate with the submitted closure reason
|
|
152
|
+
# Temporarily inject the submitted closure_reason so early-closure bypass applies
|
|
153
|
+
original_closure_reason = incident.closure_reason
|
|
154
|
+
incident.closure_reason = closure_reason
|
|
155
|
+
try:
|
|
156
|
+
can_close, reasons = incident.can_be_closed
|
|
157
|
+
finally:
|
|
158
|
+
incident.closure_reason = original_closure_reason
|
|
159
|
+
if not can_close:
|
|
160
|
+
# Build error message from reasons
|
|
161
|
+
error_messages = [reason[1] for reason in reasons]
|
|
162
|
+
error_text = "\n".join([f"• {msg}" for msg in error_messages])
|
|
163
|
+
ack(
|
|
164
|
+
response_action="errors",
|
|
165
|
+
errors={
|
|
166
|
+
"closure_message": f"Cannot close this incident:\n{error_text}"
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
# Clear ALL modals in the stack (not just this one)
|
|
172
|
+
# This ensures the underlying "Update Status" modal is also closed
|
|
173
|
+
ack(response_action="clear")
|
|
174
|
+
|
|
148
175
|
try:
|
|
149
176
|
# Update incident with closure fields
|
|
150
177
|
incident.closure_reason = closure_reason
|
|
@@ -160,9 +187,7 @@ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
|
|
|
160
187
|
)
|
|
161
188
|
|
|
162
189
|
except Exception:
|
|
163
|
-
logger.exception(
|
|
164
|
-
"Error closing incident #%s with reason", incident.id
|
|
165
|
-
)
|
|
190
|
+
logger.exception("Error closing incident #%s with reason", incident.id)
|
|
166
191
|
respond(
|
|
167
192
|
body=body,
|
|
168
193
|
text=f"❌ Failed to close incident #{incident.id}",
|
|
@@ -177,7 +202,11 @@ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
|
|
|
177
202
|
f"✅ Incident #{incident.id} has been closed.\n"
|
|
178
203
|
f"*Reason:* {ClosureReason(closure_reason).label}\n"
|
|
179
204
|
f"*Message:* {message}"
|
|
180
|
-
+ (
|
|
205
|
+
+ (
|
|
206
|
+
f"\n*Reference:* {closure_reference}"
|
|
207
|
+
if closure_reference
|
|
208
|
+
else ""
|
|
209
|
+
)
|
|
181
210
|
),
|
|
182
211
|
)
|
|
183
212
|
except SlackApiError as e:
|
|
@@ -127,12 +127,12 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
|
|
|
127
127
|
# Build error message from reasons
|
|
128
128
|
error_messages = [reason[1] for reason in reasons]
|
|
129
129
|
error_text = "\n".join([f"• {msg}" for msg in error_messages])
|
|
130
|
-
logger.warning(
|
|
130
|
+
logger.warning(
|
|
131
|
+
f"Cannot close incident #{incident.id} via Update Status: {error_text}"
|
|
132
|
+
)
|
|
131
133
|
ack(
|
|
132
134
|
response_action="errors",
|
|
133
|
-
errors={
|
|
134
|
-
"status": f"Cannot close this incident:\n{error_text}"
|
|
135
|
-
}
|
|
135
|
+
errors={"status": f"{error_text}"},
|
|
136
136
|
)
|
|
137
137
|
return
|
|
138
138
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firefighter-incident
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.27
|
|
4
4
|
Summary: Incident Management tool made for Slack using Django
|
|
5
5
|
Project-URL: Repository, https://github.com/ManoManoTech/firefighter-incident
|
|
6
6
|
Project-URL: Documentation, https://manomanotech.github.io/firefighter-incident/latest/
|