firefighter-incident 0.0.21__py3-none-any.whl → 0.0.23__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/api/serializers.py +18 -0
- firefighter/api/views/incidents.py +3 -0
- firefighter/components/avatar/avatar.html +2 -2
- firefighter/components/avatar/avatar.py +2 -2
- firefighter/confluence/models.py +66 -6
- firefighter/confluence/signals/incident_updated.py +8 -26
- firefighter/firefighter/settings/components/jira_app.py +33 -0
- firefighter/incidents/admin.py +3 -0
- firefighter/incidents/models/impact.py +3 -5
- firefighter/incidents/models/incident.py +24 -9
- firefighter/incidents/views/views.py +2 -0
- firefighter/jira_app/admin.py +15 -1
- firefighter/jira_app/apps.py +3 -0
- firefighter/jira_app/client.py +151 -3
- firefighter/jira_app/management/__init__.py +1 -0
- firefighter/jira_app/management/commands/__init__.py +1 -0
- firefighter/jira_app/migrations/0002_add_jira_postmortem_model.py +71 -0
- firefighter/jira_app/models.py +50 -0
- firefighter/jira_app/service_postmortem.py +292 -0
- firefighter/jira_app/signals/__init__.py +10 -0
- firefighter/jira_app/signals/incident_key_events_updated.py +88 -0
- firefighter/jira_app/signals/postmortem_created.py +155 -0
- firefighter/jira_app/templates/jira/postmortem/impact.txt +12 -0
- firefighter/jira_app/templates/jira/postmortem/incident_summary.txt +17 -0
- firefighter/jira_app/templates/jira/postmortem/mitigation_actions.txt +9 -0
- firefighter/jira_app/templates/jira/postmortem/root_causes.txt +12 -0
- firefighter/jira_app/templates/jira/postmortem/timeline.txt +7 -0
- firefighter/raid/signals/incident_updated.py +31 -11
- firefighter/slack/messages/slack_messages.py +39 -3
- firefighter/slack/signals/postmortem_created.py +51 -3
- firefighter/slack/views/modals/closure_reason.py +15 -0
- firefighter/slack/views/modals/key_event_message.py +9 -0
- firefighter/slack/views/modals/postmortem.py +32 -40
- firefighter/slack/views/modals/update_status.py +7 -1
- {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/METADATA +2 -1
- {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/RECORD +52 -33
- {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/WHEEL +1 -1
- firefighter_tests/test_api/test_renderer.py +41 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +29 -0
- firefighter_tests/test_jira_app/test_incident_key_events_sync.py +112 -0
- firefighter_tests/test_jira_app/test_models.py +138 -0
- firefighter_tests/test_jira_app/test_postmortem_issue_link.py +201 -0
- firefighter_tests/test_jira_app/test_postmortem_service.py +416 -0
- firefighter_tests/test_jira_app/test_timeline_template.py +135 -0
- firefighter_tests/test_raid/test_raid_signals.py +50 -8
- firefighter_tests/test_slack/messages/test_slack_messages.py +112 -23
- firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +18 -2
- firefighter_tests/test_slack/views/modals/test_key_event_message.py +30 -0
- firefighter_tests/test_slack/views/modals/test_update_status.py +161 -133
- {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/licenses/LICENSE +0 -0
firefighter/jira_app/models.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from django.conf import settings
|
|
3
4
|
from django.db import models
|
|
4
5
|
from django_stubs_ext.db.models import TypedModelMeta
|
|
5
6
|
|
|
@@ -59,3 +60,52 @@ class JiraIssue(models.Model):
|
|
|
59
60
|
|
|
60
61
|
def __str__(self) -> str:
|
|
61
62
|
return f"{self.key}: {self.summary}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class JiraPostMortem(models.Model):
|
|
66
|
+
"""Jira Post-mortem linked to an Incident."""
|
|
67
|
+
|
|
68
|
+
incident = models.OneToOneField(
|
|
69
|
+
"incidents.Incident",
|
|
70
|
+
on_delete=models.CASCADE,
|
|
71
|
+
related_name="jira_postmortem_for",
|
|
72
|
+
help_text="Incident this post-mortem is for",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
jira_issue_key = models.CharField(
|
|
76
|
+
max_length=32,
|
|
77
|
+
unique=True,
|
|
78
|
+
help_text="Jira issue key (e.g., INCIDENT-123)",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
jira_issue_id = models.CharField(
|
|
82
|
+
max_length=32,
|
|
83
|
+
unique=True,
|
|
84
|
+
help_text="Jira issue ID",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
88
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
89
|
+
|
|
90
|
+
created_by = models.ForeignKey(
|
|
91
|
+
User,
|
|
92
|
+
on_delete=models.SET_NULL,
|
|
93
|
+
null=True,
|
|
94
|
+
blank=True,
|
|
95
|
+
related_name="jira_postmortems_created",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
class Meta(TypedModelMeta):
|
|
99
|
+
db_table = "jira_postmortem"
|
|
100
|
+
verbose_name = "Jira Post-mortem"
|
|
101
|
+
verbose_name_plural = "Jira Post-mortems"
|
|
102
|
+
|
|
103
|
+
def __str__(self) -> str:
|
|
104
|
+
return (
|
|
105
|
+
f"Jira Post-mortem {self.jira_issue_key} for incident #{self.incident.id}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def issue_url(self) -> str:
|
|
110
|
+
"""Return Jira issue URL."""
|
|
111
|
+
return f"{settings.RAID_JIRA_API_URL}/browse/{self.jira_issue_key}"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Service for creating and managing Jira post-mortems."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.template.loader import render_to_string
|
|
11
|
+
|
|
12
|
+
from firefighter.jira_app.client import (
|
|
13
|
+
JiraClient,
|
|
14
|
+
JiraUserDatabaseError,
|
|
15
|
+
JiraUserNotFoundError,
|
|
16
|
+
)
|
|
17
|
+
from firefighter.jira_app.models import JiraPostMortem
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from firefighter.incidents.models.incident import Incident
|
|
21
|
+
from firefighter.incidents.models.user import User
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JiraPostMortemService:
|
|
27
|
+
"""Service for creating and managing Jira post-mortems."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.client = JiraClient()
|
|
31
|
+
self.project_key = getattr(settings, "JIRA_POSTMORTEM_PROJECT_KEY", "INCIDENT")
|
|
32
|
+
self.issue_type = getattr(settings, "JIRA_POSTMORTEM_ISSUE_TYPE", "Post-mortem")
|
|
33
|
+
self.field_ids = getattr(
|
|
34
|
+
settings,
|
|
35
|
+
"JIRA_POSTMORTEM_FIELDS",
|
|
36
|
+
{
|
|
37
|
+
"incident_summary": "customfield_12699",
|
|
38
|
+
"timeline": "customfield_12700",
|
|
39
|
+
"root_causes": "customfield_12701",
|
|
40
|
+
"impact": "customfield_12702",
|
|
41
|
+
"mitigation_actions": "customfield_12703",
|
|
42
|
+
"incident_category": "customfield_12369",
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def create_postmortem_for_incident(
|
|
47
|
+
self,
|
|
48
|
+
incident: Incident,
|
|
49
|
+
created_by: User | None = None,
|
|
50
|
+
) -> JiraPostMortem:
|
|
51
|
+
"""Create a Jira post-mortem for an incident.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
incident: Incident to create post-mortem for
|
|
55
|
+
created_by: User creating the post-mortem
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
JiraPostMortem instance
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If incident already has a Jira post-mortem
|
|
62
|
+
JiraAPIError: If Jira API call fails
|
|
63
|
+
"""
|
|
64
|
+
if hasattr(incident, "jira_postmortem_for"):
|
|
65
|
+
error_msg = f"Incident #{incident.id} already has a Jira post-mortem"
|
|
66
|
+
raise ValueError(error_msg)
|
|
67
|
+
|
|
68
|
+
logger.info(f"Creating Jira post-mortem for incident #{incident.id}")
|
|
69
|
+
|
|
70
|
+
# Prefetch incident updates and jira_ticket for timeline and parent link
|
|
71
|
+
from firefighter.incidents.models.incident import Incident # noqa: PLC0415
|
|
72
|
+
|
|
73
|
+
incident = (
|
|
74
|
+
Incident.objects.select_related("priority", "environment", "jira_ticket")
|
|
75
|
+
.prefetch_related("incidentupdate_set")
|
|
76
|
+
.get(pk=incident.pk)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Generate content from templates
|
|
80
|
+
fields = self._generate_issue_fields(incident)
|
|
81
|
+
|
|
82
|
+
# Get parent issue key from RAID Jira ticket if available
|
|
83
|
+
parent_issue_key = None
|
|
84
|
+
if hasattr(incident, "jira_ticket") and incident.jira_ticket:
|
|
85
|
+
parent_issue_key = incident.jira_ticket.key
|
|
86
|
+
|
|
87
|
+
# Create Jira issue with optional parent link
|
|
88
|
+
jira_issue = self.client.create_postmortem_issue(
|
|
89
|
+
project_key=self.project_key,
|
|
90
|
+
issue_type=self.issue_type,
|
|
91
|
+
fields=fields,
|
|
92
|
+
parent_issue_key=parent_issue_key,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Assign to incident commander if available
|
|
96
|
+
commander = (
|
|
97
|
+
incident.roles_set.select_related("user__jira_user", "role_type")
|
|
98
|
+
.filter(role_type__slug="commander")
|
|
99
|
+
.first()
|
|
100
|
+
)
|
|
101
|
+
if commander:
|
|
102
|
+
jira_user = getattr(commander.user, "jira_user", None)
|
|
103
|
+
if jira_user is None:
|
|
104
|
+
try:
|
|
105
|
+
jira_user = self.client.get_jira_user_from_user(commander.user)
|
|
106
|
+
except (JiraUserNotFoundError, JiraUserDatabaseError) as exc:
|
|
107
|
+
logger.warning(
|
|
108
|
+
"Unable to fetch Jira user for commander %s: %s",
|
|
109
|
+
commander.user_id,
|
|
110
|
+
exc,
|
|
111
|
+
)
|
|
112
|
+
if jira_user is not None:
|
|
113
|
+
assigned = self.client.assign_issue(
|
|
114
|
+
issue_key=jira_issue["key"],
|
|
115
|
+
account_id=jira_user.id,
|
|
116
|
+
)
|
|
117
|
+
if assigned:
|
|
118
|
+
logger.info(
|
|
119
|
+
"Assigned post-mortem %s to commander %s",
|
|
120
|
+
jira_issue["key"],
|
|
121
|
+
commander.user.username,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Create JiraPostMortem record
|
|
125
|
+
jira_postmortem = JiraPostMortem.objects.create(
|
|
126
|
+
incident=incident,
|
|
127
|
+
jira_issue_key=jira_issue["key"],
|
|
128
|
+
jira_issue_id=jira_issue["id"],
|
|
129
|
+
created_by=created_by,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
logger.info(
|
|
133
|
+
f"Created Jira post-mortem {jira_postmortem.jira_issue_key} "
|
|
134
|
+
f"for incident #{incident.id}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return jira_postmortem
|
|
138
|
+
|
|
139
|
+
def _generate_issue_fields(
|
|
140
|
+
self, incident: Incident
|
|
141
|
+
) -> dict[str, str | dict[str, str] | list[dict[str, str]]]:
|
|
142
|
+
"""Generate Jira issue fields from incident data.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
incident: Incident to generate fields for
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Dictionary of Jira field IDs to values
|
|
149
|
+
"""
|
|
150
|
+
# Generate summary (standard field)
|
|
151
|
+
env = getattr(settings, "ENV", "dev")
|
|
152
|
+
topic_prefix = "" if env in {"support", "prod"} else f"[IGNORE - TEST {env}] "
|
|
153
|
+
summary = (
|
|
154
|
+
f"{topic_prefix}#{incident.slack_channel_name} "
|
|
155
|
+
f"({incident.priority.name}) {incident.title}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Generate content from Django templates (Jira Wiki Markup)
|
|
159
|
+
context = {
|
|
160
|
+
"incident": incident,
|
|
161
|
+
"priority": incident.priority,
|
|
162
|
+
"created_at": incident.created_at,
|
|
163
|
+
"components": [], # No component relationship available
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
incident_summary = render_to_string(
|
|
167
|
+
"jira/postmortem/incident_summary.txt",
|
|
168
|
+
context,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
timeline = render_to_string(
|
|
172
|
+
"jira/postmortem/timeline.txt",
|
|
173
|
+
context,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
impact = render_to_string(
|
|
177
|
+
"jira/postmortem/impact.txt",
|
|
178
|
+
context,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
mitigation_actions = render_to_string(
|
|
182
|
+
"jira/postmortem/mitigation_actions.txt",
|
|
183
|
+
context,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Optional: root causes (editable placeholder for manual completion)
|
|
187
|
+
root_causes = render_to_string(
|
|
188
|
+
"jira/postmortem/root_causes.txt",
|
|
189
|
+
context,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Build field mapping
|
|
193
|
+
fields: dict[str, str | dict[str, str] | list[dict[str, str]]] = {
|
|
194
|
+
"summary": summary,
|
|
195
|
+
self.field_ids["incident_summary"]: incident_summary,
|
|
196
|
+
self.field_ids["timeline"]: timeline,
|
|
197
|
+
self.field_ids["impact"]: impact,
|
|
198
|
+
self.field_ids["mitigation_actions"]: mitigation_actions,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
due_date = self._add_business_days(incident.created_at, 40).date()
|
|
202
|
+
fields["duedate"] = due_date.isoformat()
|
|
203
|
+
|
|
204
|
+
# Add optional fields if not empty
|
|
205
|
+
if root_causes.strip():
|
|
206
|
+
fields[self.field_ids["root_causes"]] = root_causes
|
|
207
|
+
|
|
208
|
+
# Add incident category if available
|
|
209
|
+
if incident.incident_category:
|
|
210
|
+
# Jira select field requires dict with value key
|
|
211
|
+
category_field_id = self.field_ids["incident_category"]
|
|
212
|
+
fields[category_field_id] = {"value": incident.incident_category.name}
|
|
213
|
+
|
|
214
|
+
# Replicate custom fields from incident ticket to post-mortem
|
|
215
|
+
self._add_replicated_custom_fields(incident, fields)
|
|
216
|
+
|
|
217
|
+
return fields
|
|
218
|
+
|
|
219
|
+
def _add_replicated_custom_fields(
|
|
220
|
+
self,
|
|
221
|
+
incident: Incident,
|
|
222
|
+
fields: dict[str, str | dict[str, str] | list[dict[str, str]]],
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Replicate custom fields from incident ticket to post-mortem.
|
|
225
|
+
|
|
226
|
+
Replicates the following fields from the incident ticket:
|
|
227
|
+
- Priority (customfield_11064)
|
|
228
|
+
- Affected environments (customfield_11049)
|
|
229
|
+
- Zoho desk ticket (customfield_10896)
|
|
230
|
+
- Zendesk ticket (customfield_10895)
|
|
231
|
+
- Seller Contract ID (customfield_10908)
|
|
232
|
+
- Platform (customfield_10201)
|
|
233
|
+
- Business Impact (customfield_10936)
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
incident: Incident to extract fields from
|
|
237
|
+
fields: Dictionary to add fields to (modified in place)
|
|
238
|
+
"""
|
|
239
|
+
custom_fields = incident.custom_fields or {}
|
|
240
|
+
|
|
241
|
+
# Priority - customfield_11064 (option field)
|
|
242
|
+
if incident.priority:
|
|
243
|
+
priority_value = str(incident.priority.value)
|
|
244
|
+
fields["customfield_11064"] = {"value": priority_value}
|
|
245
|
+
|
|
246
|
+
# Affected environments - customfield_11049 (array field)
|
|
247
|
+
environments = custom_fields.get("environments", [])
|
|
248
|
+
if environments:
|
|
249
|
+
fields["customfield_11049"] = [{"value": env} for env in environments]
|
|
250
|
+
|
|
251
|
+
# Zendesk ticket - customfield_10895 (string field)
|
|
252
|
+
zendesk_ticket_id = custom_fields.get("zendesk_ticket_id")
|
|
253
|
+
if zendesk_ticket_id:
|
|
254
|
+
fields["customfield_10895"] = str(zendesk_ticket_id)
|
|
255
|
+
|
|
256
|
+
# Zoho desk ticket - customfield_10896 (string field)
|
|
257
|
+
zoho_desk_ticket_id = custom_fields.get("zoho_desk_ticket_id")
|
|
258
|
+
if zoho_desk_ticket_id:
|
|
259
|
+
fields["customfield_10896"] = str(zoho_desk_ticket_id)
|
|
260
|
+
|
|
261
|
+
# Seller Contract ID - customfield_10908 (string field)
|
|
262
|
+
seller_contract_id = custom_fields.get("seller_contract_id")
|
|
263
|
+
if seller_contract_id:
|
|
264
|
+
fields["customfield_10908"] = str(seller_contract_id)
|
|
265
|
+
|
|
266
|
+
# Platform - customfield_10201 (option field)
|
|
267
|
+
platform = custom_fields.get("platform")
|
|
268
|
+
if platform:
|
|
269
|
+
# Remove "platform-" prefix if present
|
|
270
|
+
platform_value = platform.replace("platform-", "") if isinstance(platform, str) else platform
|
|
271
|
+
fields["customfield_10201"] = {"value": platform_value}
|
|
272
|
+
|
|
273
|
+
# Business Impact - customfield_10936 (option field)
|
|
274
|
+
# Business impact is stored in the Jira ticket, not in custom_fields
|
|
275
|
+
if hasattr(incident, "jira_ticket") and incident.jira_ticket:
|
|
276
|
+
business_impact = incident.jira_ticket.business_impact
|
|
277
|
+
if business_impact and business_impact not in {"", "N/A"}:
|
|
278
|
+
fields["customfield_10936"] = {"value": business_impact}
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _add_business_days(start: datetime, days: int) -> datetime:
|
|
282
|
+
current = start
|
|
283
|
+
added = 0
|
|
284
|
+
while added < days:
|
|
285
|
+
current += timedelta(days=1)
|
|
286
|
+
if current.weekday() < 5:
|
|
287
|
+
added += 1
|
|
288
|
+
return current
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# Singleton instance
|
|
292
|
+
jira_postmortem_service = JiraPostMortemService()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from firefighter.jira_app.signals.incident_key_events_updated import (
|
|
4
|
+
sync_key_events_to_jira_postmortem,
|
|
5
|
+
)
|
|
6
|
+
from firefighter.jira_app.signals.postmortem_created import (
|
|
7
|
+
postmortem_created_handler,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["postmortem_created_handler", "sync_key_events_to_jira_postmortem"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Signal handlers for incident key events updates to sync with Jira post-mortem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.dispatch import receiver
|
|
10
|
+
from django.template.loader import render_to_string
|
|
11
|
+
|
|
12
|
+
from firefighter.incidents.models.incident import Incident as IncidentModel
|
|
13
|
+
from firefighter.incidents.signals import incident_key_events_updated
|
|
14
|
+
from firefighter.jira_app.client import JiraClient
|
|
15
|
+
from firefighter.jira_app.service_postmortem import JiraPostMortemService
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from firefighter.incidents.models.incident import Incident
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@receiver(signal=incident_key_events_updated)
|
|
24
|
+
def sync_key_events_to_jira_postmortem(
|
|
25
|
+
sender: Any, incident: Incident, **kwargs: dict[str, Any]
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Update Jira post-mortem timeline when key events are updated.
|
|
28
|
+
|
|
29
|
+
This handler is triggered when incident key events are updated via the web UI
|
|
30
|
+
or Slack, and syncs the timeline to the associated Jira post-mortem ticket.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
sender: The sender of the signal
|
|
34
|
+
incident: The incident whose key events were updated
|
|
35
|
+
**kwargs: Additional keyword arguments
|
|
36
|
+
"""
|
|
37
|
+
# Check if Jira post-mortem is enabled
|
|
38
|
+
if not getattr(settings, "ENABLE_JIRA_POSTMORTEM", False):
|
|
39
|
+
logger.debug("Jira post-mortem disabled, skipping timeline sync")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Check if incident has a Jira post-mortem
|
|
43
|
+
if not hasattr(incident, "jira_postmortem_for") or not incident.jira_postmortem_for:
|
|
44
|
+
logger.debug(f"Incident #{incident.id} has no Jira post-mortem, skipping timeline sync")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
jira_postmortem = incident.jira_postmortem_for
|
|
48
|
+
logger.info(
|
|
49
|
+
f"Syncing key events timeline to Jira post-mortem {jira_postmortem.jira_issue_key} "
|
|
50
|
+
f"for incident #{incident.id}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# Prefetch incident updates for timeline generation
|
|
55
|
+
incident_refreshed = (
|
|
56
|
+
IncidentModel.objects.select_related("priority", "environment")
|
|
57
|
+
.prefetch_related("incidentupdate_set")
|
|
58
|
+
.get(pk=incident.pk)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Generate updated timeline from template
|
|
62
|
+
timeline_content = render_to_string(
|
|
63
|
+
"jira/postmortem/timeline.txt",
|
|
64
|
+
{"incident": incident_refreshed},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Get the field ID for timeline from service
|
|
68
|
+
service = JiraPostMortemService()
|
|
69
|
+
timeline_field_id = service.field_ids.get("timeline")
|
|
70
|
+
|
|
71
|
+
if not timeline_field_id:
|
|
72
|
+
logger.error("Timeline field ID not found in Jira post-mortem service configuration")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Update the Jira ticket
|
|
76
|
+
client = JiraClient()
|
|
77
|
+
issue = client.jira.issue(jira_postmortem.jira_issue_key)
|
|
78
|
+
issue.update(fields={timeline_field_id: timeline_content})
|
|
79
|
+
|
|
80
|
+
logger.info(
|
|
81
|
+
f"Successfully updated timeline in Jira post-mortem {jira_postmortem.jira_issue_key}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
except Exception:
|
|
85
|
+
logger.exception(
|
|
86
|
+
f"Failed to update timeline in Jira post-mortem {jira_postmortem.jira_issue_key} "
|
|
87
|
+
f"for incident #{incident.id}"
|
|
88
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Never
|
|
5
|
+
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.dispatch.dispatcher import receiver
|
|
9
|
+
|
|
10
|
+
from firefighter.incidents.enums import IncidentStatus
|
|
11
|
+
from firefighter.incidents.signals import incident_updated
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from firefighter.confluence.models import PostMortemManager
|
|
15
|
+
from firefighter.incidents.models.incident import Incident
|
|
16
|
+
from firefighter.incidents.models.incident_update import IncidentUpdate
|
|
17
|
+
from firefighter.jira_app.service_postmortem import JiraPostMortemService
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_jira_postmortem_service() -> JiraPostMortemService:
|
|
23
|
+
"""Lazy import to avoid circular dependency."""
|
|
24
|
+
from firefighter.jira_app.service_postmortem import ( # noqa: PLC0415
|
|
25
|
+
jira_postmortem_service,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return jira_postmortem_service
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_confluence_postmortem_manager() -> type[PostMortemManager] | None:
|
|
32
|
+
"""Lazy import to avoid circular dependency with Confluence."""
|
|
33
|
+
if not apps.is_installed("firefighter.confluence"):
|
|
34
|
+
return None
|
|
35
|
+
from firefighter.confluence.models import PostMortemManager # noqa: PLC0415
|
|
36
|
+
|
|
37
|
+
return PostMortemManager
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@receiver(signal=incident_updated)
|
|
41
|
+
def postmortem_created_handler(
|
|
42
|
+
sender: Any,
|
|
43
|
+
incident: Incident,
|
|
44
|
+
incident_update: IncidentUpdate,
|
|
45
|
+
updated_fields: list[str],
|
|
46
|
+
**kwargs: Never,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Handle post-mortem creation when incident reaches MITIGATED status.
|
|
49
|
+
|
|
50
|
+
This handler is registered in jira_app to ensure it works independently
|
|
51
|
+
of Confluence being enabled. It creates post-mortems for both Confluence
|
|
52
|
+
and Jira based on their respective feature flags.
|
|
53
|
+
"""
|
|
54
|
+
logger.debug(
|
|
55
|
+
f"postmortem_created_handler called with sender={sender}, "
|
|
56
|
+
f"incident_id={incident.id}, status={incident_update.status}, "
|
|
57
|
+
f"updated_fields={updated_fields}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not apps.is_installed("firefighter.slack"):
|
|
61
|
+
logger.error("Slack app is not installed. Skipping.")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Import Slack tasks after apps are loaded
|
|
65
|
+
from firefighter.slack.tasks.reminder_postmortem import ( # noqa: PLC0415
|
|
66
|
+
publish_fixed_next_actions,
|
|
67
|
+
publish_postmortem_reminder,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
logger.debug(f"Checking sender: sender={sender}, type={type(sender)}")
|
|
71
|
+
if sender != "update_status":
|
|
72
|
+
logger.debug(f"Ignoring signal from sender={sender}")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
logger.debug("Sender is update_status, checking postmortem conditions")
|
|
76
|
+
|
|
77
|
+
# Check if we should create post-mortem(s)
|
|
78
|
+
if (
|
|
79
|
+
"_status" not in updated_fields
|
|
80
|
+
or incident_update.status
|
|
81
|
+
not in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
|
|
82
|
+
or not incident.needs_postmortem
|
|
83
|
+
):
|
|
84
|
+
logger.debug(
|
|
85
|
+
f"Not creating post-mortem: _status in fields={('_status' in updated_fields)}, "
|
|
86
|
+
f"status={incident_update.status}, needs_postmortem={incident.needs_postmortem}"
|
|
87
|
+
)
|
|
88
|
+
# For P3+ incidents, publish next actions reminder
|
|
89
|
+
if (
|
|
90
|
+
"_status" in updated_fields
|
|
91
|
+
and incident_update.status
|
|
92
|
+
in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
|
|
93
|
+
and not incident.needs_postmortem
|
|
94
|
+
):
|
|
95
|
+
publish_fixed_next_actions(incident)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
logger.info(
|
|
99
|
+
f"Creating post-mortem(s) for incident #{incident.id} "
|
|
100
|
+
f"(status={incident_update.status}, needs_postmortem={incident.needs_postmortem})"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
enable_confluence = getattr(settings, "ENABLE_CONFLUENCE", False)
|
|
104
|
+
enable_jira_postmortem = getattr(settings, "ENABLE_JIRA_POSTMORTEM", False)
|
|
105
|
+
|
|
106
|
+
confluence_pm = None
|
|
107
|
+
jira_pm = None
|
|
108
|
+
|
|
109
|
+
# Check and create Confluence post-mortem
|
|
110
|
+
if enable_confluence:
|
|
111
|
+
has_confluence = hasattr(incident, "postmortem_for")
|
|
112
|
+
logger.debug(f"Confluence enabled, has_confluence={has_confluence}")
|
|
113
|
+
if not has_confluence:
|
|
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
|
|
129
|
+
if enable_jira_postmortem:
|
|
130
|
+
has_jira = hasattr(incident, "jira_postmortem_for")
|
|
131
|
+
logger.debug(f"Jira post-mortem enabled, has_jira={has_jira}")
|
|
132
|
+
if not has_jira:
|
|
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
|
|
145
|
+
if confluence_pm or jira_pm:
|
|
146
|
+
from firefighter.incidents.signals import postmortem_created # noqa: PLC0415
|
|
147
|
+
|
|
148
|
+
logger.info(
|
|
149
|
+
f"Post-mortem(s) created for incident #{incident.id}: "
|
|
150
|
+
f"confluence={confluence_pm is not None}, jira={jira_pm is not None}"
|
|
151
|
+
)
|
|
152
|
+
postmortem_created.send_robust(sender=__name__, incident=incident)
|
|
153
|
+
|
|
154
|
+
# Publish reminder
|
|
155
|
+
publish_postmortem_reminder(incident)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
h2. Impact
|
|
2
|
+
|
|
3
|
+
h3. Affected Systems
|
|
4
|
+
{% if components %}
|
|
5
|
+
{% for component in components %}* {{ component.name }}
|
|
6
|
+
{% endfor %}
|
|
7
|
+
{% else %}_No components recorded._
|
|
8
|
+
{% endif %}
|
|
9
|
+
|
|
10
|
+
h3. User Impact
|
|
11
|
+
|
|
12
|
+
*{color:red}_TODO: Describe the impact on users and business._{color}*
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
h2. Incident Summary
|
|
2
|
+
|
|
3
|
+
*Incident:* [#{{ incident.slack_channel_name }}|{{ incident.slack_channel_url }}]
|
|
4
|
+
*Priority:* {{ incident.priority.name }} (P{{ incident.priority.value }})
|
|
5
|
+
{% if incident.description %}
|
|
6
|
+
|
|
7
|
+
h3. Description
|
|
8
|
+
|
|
9
|
+
{{ incident.description }}
|
|
10
|
+
{% endif %}
|
|
11
|
+
{% if components %}
|
|
12
|
+
|
|
13
|
+
h3. Affected Components
|
|
14
|
+
|
|
15
|
+
{% for component in components %}* {{ component.name }}
|
|
16
|
+
{% endfor %}
|
|
17
|
+
{% endif %}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
h2. Root Causes
|
|
2
|
+
|
|
3
|
+
*{color:red}_TODO: Analyze and document the root causes of this incident._{color}*
|
|
4
|
+
|
|
5
|
+
h3. Contributing Factors
|
|
6
|
+
|
|
7
|
+
* *{color:red}_TODO: Factor 1_{color}*
|
|
8
|
+
* *{color:red}_TODO: Factor 2_{color}*
|
|
9
|
+
|
|
10
|
+
h3. Why it happened
|
|
11
|
+
|
|
12
|
+
*{color:red}_TODO: Explain why this incident occurred._{color}*
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
h2. Timeline
|
|
2
|
+
|
|
3
|
+
|| Time || Event ||
|
|
4
|
+
| {{ incident.created_at|date:"Y-m-d H:i" }} UTC | Incident created ({{ incident.priority.name }}) |
|
|
5
|
+
{% for update in incident.incidentupdate_set.all|dictsort:"event_ts" %}{% if update.status %}| {{ update.event_ts|date:"Y-m-d H:i" }} UTC | Status changed to: {{ update.status.label }} |
|
|
6
|
+
{% endif %}{% if update.event_type %}| {{ update.event_ts|date:"Y-m-d H:i" }} UTC | Key event: {{ update.event_type|title }}{% if update.message %} - {{ update.message }}{% endif %} |
|
|
7
|
+
{% endif %}{% endfor %}
|