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
firefighter/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
31
|
+
__version__ = version = '0.0.27'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 27)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
firefighter/confluence/models.py
CHANGED
|
@@ -12,7 +12,6 @@ from django.db import models
|
|
|
12
12
|
from django.urls import reverse
|
|
13
13
|
from django_filters.filters import AllValuesMultipleFilter
|
|
14
14
|
|
|
15
|
-
from firefighter.confluence.service import confluence_service
|
|
16
15
|
from firefighter.firefighter.fields_forms_widgets import CustomCheckboxSelectMultiple
|
|
17
16
|
from firefighter.incidents.models.incident import Incident
|
|
18
17
|
from firefighter.incidents.signals import postmortem_created
|
|
@@ -87,11 +86,18 @@ class PostMortemManager(models.Manager["PostMortem"]):
|
|
|
87
86
|
|
|
88
87
|
return confluence_pm, jira_pm
|
|
89
88
|
|
|
89
|
+
@staticmethod
|
|
90
|
+
def create_confluence_postmortem(incident: Incident) -> PostMortem:
|
|
91
|
+
"""Create Confluence post-mortem (existing logic)."""
|
|
92
|
+
return PostMortemManager._create_confluence_postmortem(incident)
|
|
93
|
+
|
|
90
94
|
@staticmethod
|
|
91
95
|
def _create_confluence_postmortem(incident: Incident) -> PostMortem:
|
|
92
96
|
"""Create Confluence post-mortem (existing logic)."""
|
|
93
97
|
logger.info("Creating Confluence PostMortem for %s", incident)
|
|
94
98
|
|
|
99
|
+
from firefighter.confluence.service import confluence_service
|
|
100
|
+
|
|
95
101
|
topic_prefix = (
|
|
96
102
|
""
|
|
97
103
|
if settings.ENV in {"support", "prod"}
|
|
@@ -163,6 +169,9 @@ class ConfluencePage(models.Model):
|
|
|
163
169
|
|
|
164
170
|
version = models.JSONField(default=dict) # We need a callable
|
|
165
171
|
|
|
172
|
+
class Meta:
|
|
173
|
+
app_label = "confluence"
|
|
174
|
+
|
|
166
175
|
def __str__(self) -> str:
|
|
167
176
|
return self.name
|
|
168
177
|
|
|
@@ -176,6 +185,9 @@ class PostMortem(ConfluencePage):
|
|
|
176
185
|
Incident, on_delete=models.CASCADE, related_name="postmortem_for"
|
|
177
186
|
)
|
|
178
187
|
|
|
188
|
+
class Meta:
|
|
189
|
+
app_label = "confluence"
|
|
190
|
+
|
|
179
191
|
def __str__(self) -> str:
|
|
180
192
|
return self.name
|
|
181
193
|
|
|
@@ -222,6 +234,9 @@ class Runbook(ConfluencePage):
|
|
|
222
234
|
service_name = models.CharField(max_length=255)
|
|
223
235
|
service_type = models.CharField(max_length=255)
|
|
224
236
|
|
|
237
|
+
class Meta:
|
|
238
|
+
app_label = "confluence"
|
|
239
|
+
|
|
225
240
|
def __str__(self) -> str:
|
|
226
241
|
return self.name
|
|
227
242
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Management commands for incidents app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Django management commands
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Django management command to backdate an incident's mitigated_at timestamp for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.core.management.base import BaseCommand, CommandParser
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
|
|
11
|
+
from firefighter.incidents.models.incident import Incident
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Command(BaseCommand):
|
|
15
|
+
"""Backdate an incident's mitigated_at timestamp for testing post-mortem reminders."""
|
|
16
|
+
|
|
17
|
+
help = "Backdate an incident's mitigated_at timestamp by a specified number of days"
|
|
18
|
+
|
|
19
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"incident_id",
|
|
22
|
+
type=int,
|
|
23
|
+
help="ID of the incident to backdate",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--days",
|
|
27
|
+
type=int,
|
|
28
|
+
default=6,
|
|
29
|
+
help="Number of days to backdate (default: 6, to trigger 5-day reminder)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--reset",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Reset mitigated_at to current time instead of backdating",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
38
|
+
incident_id = options["incident_id"]
|
|
39
|
+
days = options["days"]
|
|
40
|
+
reset = options["reset"]
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
incident = Incident.objects.get(id=incident_id)
|
|
44
|
+
except Incident.DoesNotExist:
|
|
45
|
+
self.stdout.write(
|
|
46
|
+
self.style.ERROR(f"Incident #{incident_id} does not exist")
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if reset:
|
|
51
|
+
incident.mitigated_at = timezone.now()
|
|
52
|
+
incident.save(update_fields=["mitigated_at"])
|
|
53
|
+
self.stdout.write(
|
|
54
|
+
self.style.SUCCESS(
|
|
55
|
+
f"✅ Reset mitigated_at for incident #{incident_id} to current time: {incident.mitigated_at}"
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
old_value = incident.mitigated_at
|
|
60
|
+
new_value = timezone.now() - timedelta(days=days)
|
|
61
|
+
incident.mitigated_at = new_value
|
|
62
|
+
incident.save(update_fields=["mitigated_at"])
|
|
63
|
+
|
|
64
|
+
self.stdout.write(
|
|
65
|
+
self.style.SUCCESS(
|
|
66
|
+
f"✅ Backdated incident #{incident_id} mitigated_at:"
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
self.stdout.write(f" Old value: {old_value}")
|
|
70
|
+
self.stdout.write(f" New value: {new_value}")
|
|
71
|
+
self.stdout.write(f" Backdated by {days} days")
|
|
72
|
+
|
|
73
|
+
self.stdout.write("\nIncident details:")
|
|
74
|
+
self.stdout.write(f" ID: {incident.id}")
|
|
75
|
+
self.stdout.write(f" Title: {incident.title}")
|
|
76
|
+
self.stdout.write(f" Priority: {incident.priority.name}")
|
|
77
|
+
self.stdout.write(f" Status: {incident.status.label}")
|
|
78
|
+
self.stdout.write(f" Needs postmortem: {incident.needs_postmortem}")
|
|
79
|
+
self.stdout.write(f" Mitigated at: {incident.mitigated_at}")
|
|
80
|
+
|
|
81
|
+
if incident.needs_postmortem and days >= 5:
|
|
82
|
+
self.stdout.write(
|
|
83
|
+
self.style.WARNING(
|
|
84
|
+
"\n⚠️ This incident should now trigger a 5-day reminder!"
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
self.stdout.write(
|
|
88
|
+
"\nTo test the reminder, run:"
|
|
89
|
+
)
|
|
90
|
+
self.stdout.write(
|
|
91
|
+
self.style.NOTICE(
|
|
92
|
+
" pdm run python manage.py test_postmortem_reminders"
|
|
93
|
+
)
|
|
94
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Django management command to test post-mortem reminders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.core.management.base import BaseCommand, CommandParser
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
|
|
11
|
+
from firefighter.incidents.enums import IncidentStatus
|
|
12
|
+
from firefighter.incidents.models.incident import Incident
|
|
13
|
+
from firefighter.slack.tasks.send_postmortem_reminders import (
|
|
14
|
+
POSTMORTEM_REMINDER_DAYS,
|
|
15
|
+
send_postmortem_reminders,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Command(BaseCommand):
|
|
20
|
+
"""Test post-mortem reminders by executing the task manually."""
|
|
21
|
+
|
|
22
|
+
help = "Execute the post-mortem reminder task manually for testing"
|
|
23
|
+
|
|
24
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--list-only",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Only list eligible incidents without sending reminders",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
32
|
+
list_only = options["list_only"]
|
|
33
|
+
|
|
34
|
+
self.stdout.write(self.style.MIGRATE_HEADING("Post-Mortem Reminder Testing"))
|
|
35
|
+
self.stdout.write("=" * 70)
|
|
36
|
+
|
|
37
|
+
# Calculate cutoff date
|
|
38
|
+
cutoff_date = timezone.now() - timedelta(days=POSTMORTEM_REMINDER_DAYS)
|
|
39
|
+
|
|
40
|
+
self.stdout.write(f"\n⏰ Reminder threshold: {POSTMORTEM_REMINDER_DAYS} days")
|
|
41
|
+
self.stdout.write(f"📅 Cutoff date: {cutoff_date}")
|
|
42
|
+
self.stdout.write(f"🕐 Current time: {timezone.now()}\n")
|
|
43
|
+
|
|
44
|
+
# Find eligible incidents
|
|
45
|
+
eligible_incidents = Incident.objects.filter(
|
|
46
|
+
mitigated_at__lte=cutoff_date,
|
|
47
|
+
mitigated_at__isnull=False,
|
|
48
|
+
_status__in=[
|
|
49
|
+
IncidentStatus.MITIGATED.value,
|
|
50
|
+
IncidentStatus.POST_MORTEM.value,
|
|
51
|
+
],
|
|
52
|
+
priority__needs_postmortem=True,
|
|
53
|
+
ignore=False,
|
|
54
|
+
).select_related("priority", "environment", "conversation")
|
|
55
|
+
|
|
56
|
+
count = eligible_incidents.count()
|
|
57
|
+
self.stdout.write(f"🔍 Found {count} incident(s) eligible for reminder\n")
|
|
58
|
+
|
|
59
|
+
if count == 0:
|
|
60
|
+
self.stdout.write(
|
|
61
|
+
self.style.WARNING("⚠️ No incidents found needing reminders")
|
|
62
|
+
)
|
|
63
|
+
self.stdout.write("\nTo test, you can backdate an incident with:")
|
|
64
|
+
self.stdout.write(
|
|
65
|
+
self.style.NOTICE(
|
|
66
|
+
" pdm run python manage.py backdate_incident_mitigated <incident_id> --days 6"
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Display eligible incidents
|
|
72
|
+
for incident in eligible_incidents:
|
|
73
|
+
days_since_mitigated = (
|
|
74
|
+
timezone.now() - incident.mitigated_at
|
|
75
|
+
).days if incident.mitigated_at else 0
|
|
76
|
+
|
|
77
|
+
self.stdout.write(f" 📋 Incident #{incident.id}")
|
|
78
|
+
self.stdout.write(f" Title: {incident.title}")
|
|
79
|
+
self.stdout.write(f" Priority: {incident.priority.name}")
|
|
80
|
+
self.stdout.write(f" Status: {incident.status.label}")
|
|
81
|
+
self.stdout.write(f" Mitigated: {incident.mitigated_at}")
|
|
82
|
+
self.stdout.write(
|
|
83
|
+
f" Days since mitigated: {days_since_mitigated} days"
|
|
84
|
+
)
|
|
85
|
+
self.stdout.write(f" Environment: {incident.environment.value}")
|
|
86
|
+
self.stdout.write(f" Private: {incident.private}")
|
|
87
|
+
self.stdout.write("")
|
|
88
|
+
|
|
89
|
+
if list_only:
|
|
90
|
+
self.stdout.write(
|
|
91
|
+
self.style.SUCCESS(
|
|
92
|
+
"✅ List-only mode: No reminders sent"
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
self.stdout.write("\nTo send reminders, run without --list-only flag")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Execute the task
|
|
99
|
+
self.stdout.write("=" * 70)
|
|
100
|
+
self.stdout.write(
|
|
101
|
+
self.style.WARNING("🚀 Executing post-mortem reminder task...\n")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
send_postmortem_reminders()
|
|
106
|
+
self.stdout.write("\n" + "=" * 70)
|
|
107
|
+
self.stdout.write(
|
|
108
|
+
self.style.SUCCESS("✅ Task execution completed successfully!")
|
|
109
|
+
)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
self.stdout.write("\n" + "=" * 70)
|
|
112
|
+
self.stdout.write(self.style.ERROR(f"❌ Task execution failed: {e}"))
|
|
113
|
+
raise
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Generated by Django 4.2.21 on 2025-12-19 14:02
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("incidents", "0029_add_custom_fields_to_incident"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name="incident",
|
|
15
|
+
name="mitigated_at",
|
|
16
|
+
field=models.DateTimeField(
|
|
17
|
+
blank=True,
|
|
18
|
+
help_text="Timestamp when incident status changed to MITIGATED",
|
|
19
|
+
null=True,
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
]
|
|
@@ -214,12 +214,8 @@ class Incident(models.Model):
|
|
|
214
214
|
on_delete=models.PROTECT,
|
|
215
215
|
help_text="Priority",
|
|
216
216
|
)
|
|
217
|
-
incident_category = models.ForeignKey(
|
|
218
|
-
|
|
219
|
-
)
|
|
220
|
-
environment = models.ForeignKey(
|
|
221
|
-
Environment, on_delete=models.PROTECT
|
|
222
|
-
)
|
|
217
|
+
incident_category = models.ForeignKey(IncidentCategory, on_delete=models.PROTECT)
|
|
218
|
+
environment = models.ForeignKey(Environment, on_delete=models.PROTECT)
|
|
223
219
|
created_by = models.ForeignKey(
|
|
224
220
|
User,
|
|
225
221
|
on_delete=models.PROTECT,
|
|
@@ -229,6 +225,11 @@ class Incident(models.Model):
|
|
|
229
225
|
|
|
230
226
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
231
227
|
updated_at = models.DateTimeField(auto_now=True)
|
|
228
|
+
mitigated_at = models.DateTimeField(
|
|
229
|
+
null=True,
|
|
230
|
+
blank=True,
|
|
231
|
+
help_text="Timestamp when incident status changed to MITIGATED",
|
|
232
|
+
)
|
|
232
233
|
closed_at = models.DateTimeField(
|
|
233
234
|
null=True, blank=True
|
|
234
235
|
) # XXX-ZOR make this an event
|
|
@@ -380,6 +381,36 @@ class Incident(models.Model):
|
|
|
380
381
|
f"Incident is not in PostMortem status, and needs one because of its priority and environment ({self.priority.name}/{self.environment.value}).",
|
|
381
382
|
)
|
|
382
383
|
)
|
|
384
|
+
# If a Jira post-mortem exists, ensure it is in the expected "Ready" status
|
|
385
|
+
if hasattr(self, "jira_postmortem_for"):
|
|
386
|
+
try:
|
|
387
|
+
from firefighter.jira_app.service_postmortem import ( # noqa: PLC0415
|
|
388
|
+
jira_postmortem_service,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
is_ready, current_status = (
|
|
392
|
+
jira_postmortem_service.is_postmortem_ready(
|
|
393
|
+
self.jira_postmortem_for
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
if not is_ready:
|
|
397
|
+
cant_closed_reasons.append(
|
|
398
|
+
(
|
|
399
|
+
"POSTMORTEM_NOT_READY",
|
|
400
|
+
f"Jira post-mortem {self.jira_postmortem_for.jira_issue_key} is not Ready (current status: {current_status}).",
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
except Exception: # pragma: no cover - defensive guard
|
|
404
|
+
logger.exception(
|
|
405
|
+
"Failed to verify Jira post-mortem status for incident #%s",
|
|
406
|
+
self.id,
|
|
407
|
+
)
|
|
408
|
+
cant_closed_reasons.append(
|
|
409
|
+
(
|
|
410
|
+
"POSTMORTEM_STATUS_UNKNOWN",
|
|
411
|
+
"Could not verify Jira post-mortem status. Please check it in Jira.",
|
|
412
|
+
)
|
|
413
|
+
)
|
|
383
414
|
elif self.status.value < IncidentStatus.MITIGATED:
|
|
384
415
|
cant_closed_reasons.append(
|
|
385
416
|
(
|
|
@@ -606,7 +637,9 @@ class Incident(models.Model):
|
|
|
606
637
|
|
|
607
638
|
_update_incident_field(self, "_status", status, updated_fields)
|
|
608
639
|
_update_incident_field(self, "priority_id", priority_id, updated_fields)
|
|
609
|
-
_update_incident_field(
|
|
640
|
+
_update_incident_field(
|
|
641
|
+
self, "incident_category_id", incident_category_id, updated_fields
|
|
642
|
+
)
|
|
610
643
|
_update_incident_field(self, "title", title, updated_fields)
|
|
611
644
|
_update_incident_field(self, "description", description, updated_fields)
|
|
612
645
|
_update_incident_field(self, "environment_id", environment_id, updated_fields)
|
|
@@ -706,7 +739,9 @@ class IncidentFilterSet(django_filters.FilterSet):
|
|
|
706
739
|
widget=CustomCheckboxSelectMultiple,
|
|
707
740
|
)
|
|
708
741
|
group = ModelMultipleChoiceFilter(
|
|
709
|
-
queryset=Group.objects.all(),
|
|
742
|
+
queryset=Group.objects.all(),
|
|
743
|
+
field_name="incident_category__group_id",
|
|
744
|
+
label="Group",
|
|
710
745
|
)
|
|
711
746
|
incident_category = ModelMultipleChoiceFilter(
|
|
712
747
|
queryset=incident_category_filter_choices_queryset,
|
|
@@ -30,6 +30,9 @@ class JiraPostMortemService:
|
|
|
30
30
|
self.client = JiraClient()
|
|
31
31
|
self.project_key = getattr(settings, "JIRA_POSTMORTEM_PROJECT_KEY", "INCIDENT")
|
|
32
32
|
self.issue_type = getattr(settings, "JIRA_POSTMORTEM_ISSUE_TYPE", "Post-mortem")
|
|
33
|
+
self.ready_status_name = getattr(
|
|
34
|
+
settings, "JIRA_POSTMORTEM_READY_STATUS", "Ready"
|
|
35
|
+
)
|
|
33
36
|
self.field_ids = getattr(
|
|
34
37
|
settings,
|
|
35
38
|
"JIRA_POSTMORTEM_FIELDS",
|
|
@@ -136,6 +139,16 @@ class JiraPostMortemService:
|
|
|
136
139
|
|
|
137
140
|
return jira_postmortem
|
|
138
141
|
|
|
142
|
+
def is_postmortem_ready(self, jira_postmortem: JiraPostMortem) -> tuple[bool, str]:
|
|
143
|
+
"""Check if the Jira post-mortem issue is in the Ready status.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Tuple of (is_ready, current_status_name)
|
|
147
|
+
"""
|
|
148
|
+
issue = self.client.jira.issue(jira_postmortem.jira_issue_id)
|
|
149
|
+
status_name: str = getattr(issue.fields.status, "name", "")
|
|
150
|
+
return status_name == self.ready_status_name, status_name
|
|
151
|
+
|
|
139
152
|
def _generate_issue_fields(
|
|
140
153
|
self, incident: Incident
|
|
141
154
|
) -> dict[str, str | dict[str, str] | list[dict[str, str]]]:
|
|
@@ -229,7 +242,7 @@ class JiraPostMortemService:
|
|
|
229
242
|
- Zoho desk ticket (customfield_10896)
|
|
230
243
|
- Zendesk ticket (customfield_10895)
|
|
231
244
|
- Seller Contract ID (customfield_10908)
|
|
232
|
-
-
|
|
245
|
+
- Platforms (customfield_10201) - first platform from list
|
|
233
246
|
- Business Impact (customfield_10936)
|
|
234
247
|
|
|
235
248
|
Args:
|
|
@@ -264,9 +277,10 @@ class JiraPostMortemService:
|
|
|
264
277
|
fields["customfield_10908"] = str(seller_contract_id)
|
|
265
278
|
|
|
266
279
|
# Platform - customfield_10201 (option field)
|
|
267
|
-
|
|
268
|
-
if
|
|
269
|
-
#
|
|
280
|
+
platforms = custom_fields.get("platforms", [])
|
|
281
|
+
if platforms:
|
|
282
|
+
# Extract first platform and remove "platform-" prefix if present
|
|
283
|
+
platform = platforms[0] if isinstance(platforms, list) else platforms
|
|
270
284
|
platform_value = platform.replace("platform-", "") if isinstance(platform, str) else platform
|
|
271
285
|
fields["customfield_10201"] = {"value": platform_value}
|
|
272
286
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import importlib
|
|
3
4
|
import logging
|
|
4
5
|
from typing import TYPE_CHECKING, Any, Never
|
|
5
6
|
|
|
6
7
|
from django.apps import apps
|
|
7
8
|
from django.conf import settings
|
|
8
9
|
from django.dispatch.dispatcher import receiver
|
|
10
|
+
from django.utils import timezone
|
|
9
11
|
|
|
10
12
|
from firefighter.incidents.enums import IncidentStatus
|
|
11
13
|
from firefighter.incidents.signals import incident_updated
|
|
@@ -21,20 +23,103 @@ logger = logging.getLogger(__name__)
|
|
|
21
23
|
|
|
22
24
|
def _get_jira_postmortem_service() -> JiraPostMortemService:
|
|
23
25
|
"""Lazy import to avoid circular dependency."""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
return jira_postmortem_service
|
|
26
|
+
module = importlib.import_module("firefighter.jira_app.service_postmortem")
|
|
27
|
+
return module.jira_postmortem_service
|
|
29
28
|
|
|
30
29
|
|
|
31
30
|
def _get_confluence_postmortem_manager() -> type[PostMortemManager] | None:
|
|
32
31
|
"""Lazy import to avoid circular dependency with Confluence."""
|
|
33
32
|
if not apps.is_installed("firefighter.confluence"):
|
|
34
33
|
return None
|
|
35
|
-
|
|
34
|
+
module = importlib.import_module("firefighter.confluence.models")
|
|
35
|
+
return module.PostMortemManager
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _update_mitigated_at_timestamp(
|
|
39
|
+
incident: Incident, incident_update: IncidentUpdate, updated_fields: list[str]
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Update mitigated_at timestamp when incident status changes to MITIGATED."""
|
|
42
|
+
if (
|
|
43
|
+
"_status" in updated_fields
|
|
44
|
+
and incident_update.status == IncidentStatus.MITIGATED
|
|
45
|
+
and incident.mitigated_at is None
|
|
46
|
+
):
|
|
47
|
+
incident.mitigated_at = timezone.now()
|
|
48
|
+
incident.save(update_fields=["mitigated_at"])
|
|
49
|
+
logger.info(f"Set mitigated_at timestamp for incident #{incident.id}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _create_confluence_postmortem(incident: Incident) -> Any | None:
|
|
53
|
+
"""Create Confluence post-mortem if needed."""
|
|
54
|
+
has_confluence = hasattr(incident, "postmortem_for")
|
|
55
|
+
logger.debug(f"Confluence enabled, has_confluence={has_confluence}")
|
|
56
|
+
|
|
57
|
+
if has_confluence:
|
|
58
|
+
logger.debug(f"Confluence post-mortem already exists for incident #{incident.id}")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
confluence_manager = _get_confluence_postmortem_manager()
|
|
62
|
+
if not confluence_manager:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
logger.info(f"Creating Confluence post-mortem for incident #{incident.id}")
|
|
66
|
+
try:
|
|
67
|
+
# Use the public API specifically for Confluence creation
|
|
68
|
+
return confluence_manager.create_confluence_postmortem(incident)
|
|
69
|
+
except Exception:
|
|
70
|
+
logger.exception(
|
|
71
|
+
f"Failed to create Confluence post-mortem for incident #{incident.id}"
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _create_jira_postmortem(incident: Incident) -> Any | None:
|
|
77
|
+
"""Create Jira post-mortem if needed."""
|
|
78
|
+
has_jira = hasattr(incident, "jira_postmortem_for")
|
|
79
|
+
logger.debug(f"Jira post-mortem enabled, has_jira={has_jira}")
|
|
80
|
+
|
|
81
|
+
if has_jira:
|
|
82
|
+
logger.debug(f"Jira post-mortem already exists for incident #{incident.id}")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
logger.info(f"Creating Jira post-mortem for incident #{incident.id}")
|
|
86
|
+
try:
|
|
87
|
+
jira_service = _get_jira_postmortem_service()
|
|
88
|
+
return jira_service.create_postmortem_for_incident(incident)
|
|
89
|
+
except Exception:
|
|
90
|
+
logger.exception(
|
|
91
|
+
f"Failed to create Jira post-mortem for incident #{incident.id}"
|
|
92
|
+
)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _publish_postmortem_announcement(incident: Incident) -> None:
|
|
97
|
+
"""Publish post-mortem creation announcement to #critical-incidents."""
|
|
98
|
+
# Dynamic imports to avoid circular dependencies
|
|
99
|
+
slack_messages = importlib.import_module("firefighter.slack.messages.slack_messages")
|
|
100
|
+
slack_models = importlib.import_module("firefighter.slack.models.conversation")
|
|
101
|
+
slack_rules = importlib.import_module("firefighter.slack.rules")
|
|
102
|
+
|
|
103
|
+
announcement_class = slack_messages.SlackMessageIncidentPostMortemCreatedAnnouncement
|
|
104
|
+
conversation_class = slack_models.Conversation
|
|
105
|
+
should_publish_pm_in_general_channel = slack_rules.should_publish_pm_in_general_channel
|
|
106
|
+
|
|
107
|
+
if not should_publish_pm_in_general_channel(incident):
|
|
108
|
+
return
|
|
36
109
|
|
|
37
|
-
|
|
110
|
+
tech_incidents_conversation = conversation_class.objects.get_or_none(
|
|
111
|
+
tag="tech_incidents"
|
|
112
|
+
)
|
|
113
|
+
if tech_incidents_conversation:
|
|
114
|
+
announcement = announcement_class(incident)
|
|
115
|
+
tech_incidents_conversation.send_message_and_save(announcement)
|
|
116
|
+
logger.info(
|
|
117
|
+
f"Post-mortem creation announced in tech_incidents for incident #{incident.id}"
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
logger.warning(
|
|
121
|
+
"Could not find tech_incidents conversation! Is there a channel with tag tech_incidents?"
|
|
122
|
+
)
|
|
38
123
|
|
|
39
124
|
|
|
40
125
|
@receiver(signal=incident_updated)
|
|
@@ -62,10 +147,9 @@ def postmortem_created_handler(
|
|
|
62
147
|
return
|
|
63
148
|
|
|
64
149
|
# Import Slack tasks after apps are loaded
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
150
|
+
slack_tasks = importlib.import_module("firefighter.slack.tasks.reminder_postmortem")
|
|
151
|
+
publish_fixed_next_actions = slack_tasks.publish_fixed_next_actions
|
|
152
|
+
publish_postmortem_reminder = slack_tasks.publish_postmortem_reminder
|
|
69
153
|
|
|
70
154
|
logger.debug(f"Checking sender: sender={sender}, type={type(sender)}")
|
|
71
155
|
if sender != "update_status":
|
|
@@ -74,6 +158,9 @@ def postmortem_created_handler(
|
|
|
74
158
|
|
|
75
159
|
logger.debug("Sender is update_status, checking postmortem conditions")
|
|
76
160
|
|
|
161
|
+
# Update mitigated_at timestamp
|
|
162
|
+
_update_mitigated_at_timestamp(incident, incident_update, updated_fields)
|
|
163
|
+
|
|
77
164
|
# Check if we should create post-mortem(s)
|
|
78
165
|
if (
|
|
79
166
|
"_status" not in updated_fields
|
|
@@ -106,50 +193,25 @@ def postmortem_created_handler(
|
|
|
106
193
|
confluence_pm = None
|
|
107
194
|
jira_pm = None
|
|
108
195
|
|
|
109
|
-
#
|
|
196
|
+
# Create Confluence post-mortem if enabled
|
|
110
197
|
if enable_confluence:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
confluence_manager = _get_confluence_postmortem_manager()
|
|
115
|
-
if confluence_manager:
|
|
116
|
-
logger.info(f"Creating Confluence post-mortem for incident #{incident.id}")
|
|
117
|
-
try:
|
|
118
|
-
confluence_pm = confluence_manager._create_confluence_postmortem( # noqa: SLF001
|
|
119
|
-
incident
|
|
120
|
-
)
|
|
121
|
-
except Exception:
|
|
122
|
-
logger.exception(
|
|
123
|
-
f"Failed to create Confluence post-mortem for incident #{incident.id}"
|
|
124
|
-
)
|
|
125
|
-
else:
|
|
126
|
-
logger.debug(f"Confluence post-mortem already exists for incident #{incident.id}")
|
|
127
|
-
|
|
128
|
-
# Check and create Jira post-mortem
|
|
198
|
+
confluence_pm = _create_confluence_postmortem(incident)
|
|
199
|
+
|
|
200
|
+
# Create Jira post-mortem if enabled
|
|
129
201
|
if enable_jira_postmortem:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
logger.info(f"Creating Jira post-mortem for incident #{incident.id}")
|
|
134
|
-
try:
|
|
135
|
-
jira_service = _get_jira_postmortem_service()
|
|
136
|
-
jira_pm = jira_service.create_postmortem_for_incident(incident)
|
|
137
|
-
except Exception:
|
|
138
|
-
logger.exception(
|
|
139
|
-
f"Failed to create Jira post-mortem for incident #{incident.id}"
|
|
140
|
-
)
|
|
141
|
-
else:
|
|
142
|
-
logger.debug(f"Jira post-mortem already exists for incident #{incident.id}")
|
|
143
|
-
|
|
144
|
-
# Send signal if at least one post-mortem was created
|
|
202
|
+
jira_pm = _create_jira_postmortem(incident)
|
|
203
|
+
|
|
204
|
+
# Send signal and announcements if at least one post-mortem was created
|
|
145
205
|
if confluence_pm or jira_pm:
|
|
146
|
-
|
|
206
|
+
signals_module = importlib.import_module("firefighter.incidents.signals")
|
|
207
|
+
postmortem_created = signals_module.postmortem_created
|
|
147
208
|
|
|
148
209
|
logger.info(
|
|
149
210
|
f"Post-mortem(s) created for incident #{incident.id}: "
|
|
150
211
|
f"confluence={confluence_pm is not None}, jira={jira_pm is not None}"
|
|
151
212
|
)
|
|
152
213
|
postmortem_created.send_robust(sender=__name__, incident=incident)
|
|
214
|
+
_publish_postmortem_announcement(incident)
|
|
153
215
|
|
|
154
216
|
# Publish reminder
|
|
155
217
|
publish_postmortem_reminder(incident)
|