firefighter-incident 0.0.22__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/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.22.dist-info → firefighter_incident-0.0.23.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/RECORD +50 -31
- {firefighter_incident-0.0.22.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 -129
- {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.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.23'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 23)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
firefighter/api/serializers.py
CHANGED
|
@@ -219,6 +219,8 @@ class IncidentSerializer(TaggitSerializer, serializers.ModelSerializer[Incident]
|
|
|
219
219
|
created_by = UserSerializer(read_only=True)
|
|
220
220
|
slack_channel_name = serializers.SerializerMethodField()
|
|
221
221
|
postmortem_url = serializers.SerializerMethodField()
|
|
222
|
+
jira_ticket_key = serializers.SerializerMethodField()
|
|
223
|
+
jira_ticket_url = serializers.SerializerMethodField()
|
|
222
224
|
|
|
223
225
|
created_by_email = CreatableSlugRelatedField[User](
|
|
224
226
|
source="created_by",
|
|
@@ -260,6 +262,20 @@ class IncidentSerializer(TaggitSerializer, serializers.ModelSerializer[Incident]
|
|
|
260
262
|
return obj.postmortem_for.page_url
|
|
261
263
|
return None
|
|
262
264
|
|
|
265
|
+
@staticmethod
|
|
266
|
+
def get_jira_ticket_key(obj: Incident) -> str | None:
|
|
267
|
+
"""Return the Jira ticket key if it exists."""
|
|
268
|
+
if hasattr(obj, "jira_ticket") and obj.jira_ticket:
|
|
269
|
+
return obj.jira_ticket.key
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def get_jira_ticket_url(obj: Incident) -> str | None:
|
|
274
|
+
"""Return the Jira ticket URL if it exists."""
|
|
275
|
+
if hasattr(obj, "jira_ticket") and obj.jira_ticket:
|
|
276
|
+
return obj.jira_ticket.url
|
|
277
|
+
return None
|
|
278
|
+
|
|
263
279
|
def create(self, validated_data: dict[str, Any]) -> Incident:
|
|
264
280
|
return Incident.objects.declare(**validated_data)
|
|
265
281
|
|
|
@@ -288,6 +304,8 @@ class IncidentSerializer(TaggitSerializer, serializers.ModelSerializer[Incident]
|
|
|
288
304
|
"slack_channel_name",
|
|
289
305
|
"status_page_url",
|
|
290
306
|
"postmortem_url",
|
|
307
|
+
"jira_ticket_key",
|
|
308
|
+
"jira_ticket_url",
|
|
291
309
|
"status",
|
|
292
310
|
"environment_id",
|
|
293
311
|
"incident_category_id",
|
|
@@ -78,6 +78,7 @@ class IncidentViewSet(
|
|
|
78
78
|
"environment",
|
|
79
79
|
"conversation",
|
|
80
80
|
"created_by",
|
|
81
|
+
"jira_ticket",
|
|
81
82
|
)
|
|
82
83
|
.prefetch_related(
|
|
83
84
|
Prefetch(
|
|
@@ -118,6 +119,8 @@ class IncidentViewSet(
|
|
|
118
119
|
"created_at",
|
|
119
120
|
"slack_channel_name",
|
|
120
121
|
"status_page_url",
|
|
122
|
+
"jira_ticket_key",
|
|
123
|
+
"jira_ticket_url",
|
|
121
124
|
"metrics.*.duration_seconds",
|
|
122
125
|
"costs.*.amount",
|
|
123
126
|
"roles.*.email",
|
firefighter/confluence/models.py
CHANGED
|
@@ -19,16 +19,78 @@ from firefighter.incidents.signals import postmortem_created
|
|
|
19
19
|
|
|
20
20
|
if TYPE_CHECKING:
|
|
21
21
|
from django.db.models import QuerySet
|
|
22
|
+
|
|
23
|
+
from firefighter.jira_app.models import JiraPostMortem
|
|
24
|
+
from firefighter.jira_app.service_postmortem import JiraPostMortemService
|
|
25
|
+
|
|
22
26
|
logger = logging.getLogger(__name__)
|
|
23
27
|
|
|
24
28
|
|
|
29
|
+
def _get_jira_postmortem_service() -> JiraPostMortemService:
|
|
30
|
+
"""Lazy import to avoid circular dependency with jira_app."""
|
|
31
|
+
from firefighter.jira_app.service_postmortem import jira_postmortem_service
|
|
32
|
+
|
|
33
|
+
return jira_postmortem_service
|
|
34
|
+
|
|
35
|
+
|
|
25
36
|
class PostMortemManager(models.Manager["PostMortem"]):
|
|
26
37
|
@staticmethod
|
|
27
|
-
def create_postmortem_for_incident(
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
def create_postmortem_for_incident(
|
|
39
|
+
incident: Incident,
|
|
40
|
+
) -> tuple[PostMortem | None, JiraPostMortem | None]:
|
|
41
|
+
"""Create post-mortem(s) for incident based on feature flags.
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple of (confluence_postmortem, jira_postmortem)
|
|
45
|
+
Either or both can be None depending on feature flags
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: If both backends are disabled or post-mortem already exists
|
|
49
|
+
"""
|
|
50
|
+
confluence_pm = None
|
|
51
|
+
jira_pm = None
|
|
52
|
+
|
|
53
|
+
enable_confluence = getattr(settings, "ENABLE_CONFLUENCE", False)
|
|
54
|
+
enable_jira_postmortem = getattr(settings, "ENABLE_JIRA_POSTMORTEM", False)
|
|
55
|
+
|
|
56
|
+
# Check Confluence post-mortem
|
|
57
|
+
if enable_confluence:
|
|
58
|
+
if hasattr(incident, "postmortem_for"):
|
|
59
|
+
logger.warning(
|
|
60
|
+
f"Incident #{incident.id} already has a Confluence post-mortem"
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
confluence_pm = PostMortemManager._create_confluence_postmortem(
|
|
64
|
+
incident
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Check Jira post-mortem
|
|
68
|
+
if enable_jira_postmortem:
|
|
69
|
+
if hasattr(incident, "jira_postmortem_for"):
|
|
70
|
+
logger.warning(
|
|
71
|
+
f"Incident #{incident.id} already has a Jira post-mortem"
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
jira_pm = _get_jira_postmortem_service().create_postmortem_for_incident(
|
|
75
|
+
incident
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Validate at least one was created
|
|
79
|
+
if confluence_pm is None and jira_pm is None:
|
|
80
|
+
if not enable_confluence and not enable_jira_postmortem:
|
|
81
|
+
raise ValueError("Both Confluence and Jira post-mortems are disabled")
|
|
82
|
+
raise ValueError("Post-mortem already exists for this incident")
|
|
83
|
+
|
|
84
|
+
# Send signal if at least one post-mortem was created
|
|
85
|
+
if confluence_pm or jira_pm:
|
|
86
|
+
postmortem_created.send_robust(sender=__name__, incident=incident)
|
|
87
|
+
|
|
88
|
+
return confluence_pm, jira_pm
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _create_confluence_postmortem(incident: Incident) -> PostMortem:
|
|
92
|
+
"""Create Confluence post-mortem (existing logic)."""
|
|
93
|
+
logger.info("Creating Confluence PostMortem for %s", incident)
|
|
32
94
|
|
|
33
95
|
topic_prefix = (
|
|
34
96
|
""
|
|
@@ -58,8 +120,6 @@ class PostMortemManager(models.Manager["PostMortem"]):
|
|
|
58
120
|
)
|
|
59
121
|
pm_page.save()
|
|
60
122
|
|
|
61
|
-
postmortem_created.send_robust(sender=__name__, incident=incident)
|
|
62
|
-
|
|
63
123
|
previous_postmortem = (
|
|
64
124
|
PostMortem.objects.exclude(id=pm_page.id)
|
|
65
125
|
.exclude(incident__isnull=True)
|
|
@@ -6,20 +6,12 @@ from typing import TYPE_CHECKING, Any, Never
|
|
|
6
6
|
from django.apps import apps
|
|
7
7
|
from django.dispatch.dispatcher import receiver
|
|
8
8
|
|
|
9
|
-
from firefighter.confluence.models import PostMortem
|
|
10
|
-
from firefighter.incidents.enums import IncidentStatus
|
|
11
9
|
from firefighter.incidents.signals import incident_updated
|
|
12
10
|
|
|
13
11
|
if TYPE_CHECKING:
|
|
14
12
|
from firefighter.incidents.models.incident import Incident
|
|
15
13
|
from firefighter.incidents.models.incident_update import IncidentUpdate
|
|
16
14
|
|
|
17
|
-
if apps.is_installed("firefighter.slack"):
|
|
18
|
-
from firefighter.slack.tasks.reminder_postmortem import (
|
|
19
|
-
publish_fixed_next_actions,
|
|
20
|
-
publish_postmortem_reminder,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
15
|
logger = logging.getLogger(__name__)
|
|
24
16
|
|
|
25
17
|
|
|
@@ -31,24 +23,14 @@ def incident_updated_handler(
|
|
|
31
23
|
updated_fields: list[str],
|
|
32
24
|
**kwargs: Never,
|
|
33
25
|
) -> None:
|
|
26
|
+
"""Handle Confluence-specific incident updates.
|
|
27
|
+
|
|
28
|
+
Note: Post-mortem creation logic has been moved to jira_app.signals.postmortem_created
|
|
29
|
+
to handle both Confluence and Jira post-mortems independently of Confluence being enabled.
|
|
30
|
+
"""
|
|
34
31
|
if not apps.is_installed("firefighter.slack"):
|
|
35
32
|
logger.error("Slack app is not installed. Skipping.")
|
|
36
33
|
return
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"_status" in updated_fields
|
|
41
|
-
and incident_update.status
|
|
42
|
-
in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
|
|
43
|
-
and incident.needs_postmortem
|
|
44
|
-
):
|
|
45
|
-
if not hasattr(incident, "postmortem_for"):
|
|
46
|
-
PostMortem.objects.create_postmortem_for_incident(incident)
|
|
47
|
-
publish_postmortem_reminder(incident)
|
|
48
|
-
elif (
|
|
49
|
-
"_status" in updated_fields
|
|
50
|
-
and incident_update.status
|
|
51
|
-
in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
|
|
52
|
-
and not incident.needs_postmortem
|
|
53
|
-
):
|
|
54
|
-
publish_fixed_next_actions(incident)
|
|
34
|
+
|
|
35
|
+
# This handler is now empty but kept for future Confluence-specific logic
|
|
36
|
+
# Post-mortem creation is handled by jira_app.signals.postmortem_created
|
|
@@ -19,3 +19,36 @@ if ENABLE_JIRA:
|
|
|
19
19
|
if not RAID_JIRA_API_URL.startswith("http"):
|
|
20
20
|
RAID_JIRA_API_URL = f"https://{RAID_JIRA_API_URL}"
|
|
21
21
|
RAID_JIRA_API_URL = RAID_JIRA_API_URL.rstrip("/")
|
|
22
|
+
|
|
23
|
+
# Jira Post-mortem Configuration
|
|
24
|
+
ENABLE_JIRA_POSTMORTEM: bool = config(
|
|
25
|
+
"ENABLE_JIRA_POSTMORTEM", cast=bool, default=False
|
|
26
|
+
)
|
|
27
|
+
"Enable Jira post-mortem creation (in addition to or instead of Confluence)."
|
|
28
|
+
|
|
29
|
+
if ENABLE_JIRA_POSTMORTEM:
|
|
30
|
+
JIRA_POSTMORTEM_PROJECT_KEY: str = config(
|
|
31
|
+
"JIRA_POSTMORTEM_PROJECT_KEY", default="INCIDENT"
|
|
32
|
+
)
|
|
33
|
+
"Jira project key for post-mortems."
|
|
34
|
+
|
|
35
|
+
JIRA_POSTMORTEM_ISSUE_TYPE: str = config(
|
|
36
|
+
"JIRA_POSTMORTEM_ISSUE_TYPE", default="Post-mortem"
|
|
37
|
+
)
|
|
38
|
+
"Jira issue type for post-mortems."
|
|
39
|
+
|
|
40
|
+
# Jira Custom Field IDs
|
|
41
|
+
JIRA_POSTMORTEM_FIELDS: dict[str, str] = {
|
|
42
|
+
"incident_summary": config(
|
|
43
|
+
"JIRA_FIELD_INCIDENT_SUMMARY", default="customfield_12699"
|
|
44
|
+
),
|
|
45
|
+
"timeline": config("JIRA_FIELD_TIMELINE", default="customfield_12700"),
|
|
46
|
+
"root_causes": config("JIRA_FIELD_ROOT_CAUSES", default="customfield_12701"),
|
|
47
|
+
"impact": config("JIRA_FIELD_IMPACT", default="customfield_12702"),
|
|
48
|
+
"mitigation_actions": config(
|
|
49
|
+
"JIRA_FIELD_MITIGATION_ACTIONS", default="customfield_12703"
|
|
50
|
+
),
|
|
51
|
+
"incident_category": config(
|
|
52
|
+
"JIRA_FIELD_INCIDENT_CATEGORY", default="customfield_12369"
|
|
53
|
+
),
|
|
54
|
+
}
|
firefighter/incidents/admin.py
CHANGED
|
@@ -6,6 +6,7 @@ from functools import cached_property
|
|
|
6
6
|
from typing import TYPE_CHECKING, Any
|
|
7
7
|
|
|
8
8
|
from django import apps
|
|
9
|
+
from django.conf import settings
|
|
9
10
|
from django.contrib import admin
|
|
10
11
|
from django.contrib.admin import AdminSite, helpers
|
|
11
12
|
from django.contrib.admin.decorators import action
|
|
@@ -514,6 +515,8 @@ class IncidentAdmin(admin.ModelAdmin[Incident]):
|
|
|
514
515
|
]
|
|
515
516
|
if apps.apps.is_installed("firefighter.confluence"):
|
|
516
517
|
select_related.append("postmortem_for")
|
|
518
|
+
if getattr(settings, "ENABLE_JIRA_POSTMORTEM", False):
|
|
519
|
+
select_related.append("jira_postmortem_for")
|
|
517
520
|
return select_related
|
|
518
521
|
|
|
519
522
|
|
|
@@ -12,8 +12,6 @@ from django.utils.translation import gettext_lazy as _
|
|
|
12
12
|
from django_stubs_ext.db.models import TypedModelMeta
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
|
-
from django.db.models.fields.related import ManyToManyField
|
|
16
|
-
|
|
17
15
|
from firefighter.incidents.models.incident import Incident # noqa: F401
|
|
18
16
|
|
|
19
17
|
|
|
@@ -50,7 +48,7 @@ class LevelChoices(models.TextChoices):
|
|
|
50
48
|
self.LOWEST: 5,
|
|
51
49
|
self.NONE: 6,
|
|
52
50
|
}
|
|
53
|
-
return priority_mapping.get(self, 6) # type: ignore
|
|
51
|
+
return priority_mapping.get(self, 6) # type: ignore[call-overload]
|
|
54
52
|
|
|
55
53
|
@property
|
|
56
54
|
def emoji(self) -> str:
|
|
@@ -64,7 +62,7 @@ class LevelChoices(models.TextChoices):
|
|
|
64
62
|
self.LOWEST: "⏬",
|
|
65
63
|
self.NONE: none_emoji,
|
|
66
64
|
}
|
|
67
|
-
return emoji_mapping.get(self, none_emoji) # type: ignore
|
|
65
|
+
return emoji_mapping.get(self, none_emoji) # type: ignore[call-overload]
|
|
68
66
|
|
|
69
67
|
|
|
70
68
|
class ImpactLevel(models.Model):
|
|
@@ -156,4 +154,4 @@ class IncidentImpact(models.Model):
|
|
|
156
154
|
|
|
157
155
|
class HasImpactProtocol(Protocol):
|
|
158
156
|
id: Any
|
|
159
|
-
impacts:
|
|
157
|
+
impacts: Any
|
|
@@ -69,6 +69,8 @@ if TYPE_CHECKING:
|
|
|
69
69
|
|
|
70
70
|
if settings.ENABLE_CONFLUENCE:
|
|
71
71
|
from firefighter.confluence.models import PostMortem
|
|
72
|
+
if getattr(settings, "ENABLE_JIRA_POSTMORTEM", False):
|
|
73
|
+
from firefighter.jira_app.models import JiraPostMortem
|
|
72
74
|
|
|
73
75
|
|
|
74
76
|
NON_ALPHANUMERIC_CHARACTERS = re.compile(r"[^\da-zA-Z]+")
|
|
@@ -338,15 +340,25 @@ class Incident(models.Model):
|
|
|
338
340
|
|
|
339
341
|
@property
|
|
340
342
|
def needs_postmortem(self) -> bool:
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
343
|
+
"""Check if incident requires a post-mortem based on priority and environment.
|
|
344
|
+
|
|
345
|
+
Post-mortem is required if:
|
|
346
|
+
- Priority requires it (P1/P2)
|
|
347
|
+
- Environment is PRD
|
|
348
|
+
- At least one post-mortem system is enabled (Confluence OR Jira)
|
|
349
|
+
"""
|
|
350
|
+
# Check if at least one post-mortem system is enabled
|
|
351
|
+
has_confluence = apps.is_installed("firefighter.confluence")
|
|
352
|
+
has_jira_postmortem = getattr(settings, "ENABLE_JIRA_POSTMORTEM", False)
|
|
353
|
+
|
|
354
|
+
if not (has_confluence or has_jira_postmortem):
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
return bool(
|
|
358
|
+
self.priority
|
|
359
|
+
and self.environment
|
|
360
|
+
and self.priority.needs_postmortem
|
|
361
|
+
and self.environment.value == "PRD"
|
|
350
362
|
)
|
|
351
363
|
|
|
352
364
|
@property
|
|
@@ -649,6 +661,9 @@ class Incident(models.Model):
|
|
|
649
661
|
if settings.ENABLE_CONFLUENCE:
|
|
650
662
|
postmortem_for: PostMortem
|
|
651
663
|
postmortem_for_id: UUID
|
|
664
|
+
if getattr(settings, "ENABLE_JIRA_POSTMORTEM", False):
|
|
665
|
+
jira_postmortem_for: JiraPostMortem
|
|
666
|
+
jira_postmortem_for_id: UUID
|
|
652
667
|
priority_id: UUID
|
|
653
668
|
environment_id: UUID
|
|
654
669
|
component_id: UUID
|
|
@@ -150,6 +150,8 @@ class IncidentDetailView(CustomDetailView[Incident]):
|
|
|
150
150
|
]
|
|
151
151
|
if settings.ENABLE_CONFLUENCE:
|
|
152
152
|
select_related.append("postmortem_for")
|
|
153
|
+
if getattr(settings, "ENABLE_JIRA_POSTMORTEM", False):
|
|
154
|
+
select_related.append("jira_postmortem_for")
|
|
153
155
|
queryset = Incident.objects.select_related(*select_related).prefetch_related(
|
|
154
156
|
Prefetch(
|
|
155
157
|
"incidentupdate_set",
|
firefighter/jira_app/admin.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from django.contrib import admin
|
|
4
4
|
|
|
5
|
-
from firefighter.jira_app.models import JiraIssue, JiraUser
|
|
5
|
+
from firefighter.jira_app.models import JiraIssue, JiraPostMortem, JiraUser
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
@admin.register(JiraUser)
|
|
@@ -22,3 +22,17 @@ class JiraIssueAdmin(admin.ModelAdmin[JiraIssue]):
|
|
|
22
22
|
search_fields = ["id", "key", "summary", "description"]
|
|
23
23
|
|
|
24
24
|
autocomplete_fields = ["watchers", "assignee", "reporter"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@admin.register(JiraPostMortem)
|
|
28
|
+
class JiraPostMortemAdmin(admin.ModelAdmin[JiraPostMortem]):
|
|
29
|
+
model = JiraPostMortem
|
|
30
|
+
list_display = ["jira_issue_key", "incident", "created_at", "created_by"]
|
|
31
|
+
list_display_links = ["jira_issue_key", "incident"]
|
|
32
|
+
search_fields = ["jira_issue_key", "jira_issue_id", "incident__id"]
|
|
33
|
+
list_select_related = ["incident", "created_by"]
|
|
34
|
+
autocomplete_fields = ["incident", "created_by"]
|
|
35
|
+
readonly_fields = ["created_at", "updated_at", "issue_url"]
|
|
36
|
+
|
|
37
|
+
def issue_url(self, obj: JiraPostMortem) -> str:
|
|
38
|
+
return obj.issue_url
|
firefighter/jira_app/apps.py
CHANGED
|
@@ -10,6 +10,9 @@ class JiraAppConfig(AppConfig):
|
|
|
10
10
|
verbose_name = "Jira"
|
|
11
11
|
|
|
12
12
|
def ready(self) -> None:
|
|
13
|
+
# Register signals
|
|
14
|
+
# E.g. usage: create PostMortem (Jira and/or Confluence) on incident_updated
|
|
15
|
+
import firefighter.jira_app.signals
|
|
13
16
|
import firefighter.jira_app.tasks
|
|
14
17
|
|
|
15
18
|
return super().ready()
|
firefighter/jira_app/client.py
CHANGED
|
@@ -268,9 +268,7 @@ class JiraClient:
|
|
|
268
268
|
raise
|
|
269
269
|
else:
|
|
270
270
|
if len(watchers) == 0:
|
|
271
|
-
logger.debug(
|
|
272
|
-
"No watchers found for jira_issue_id '%s'.", jira_issue_id
|
|
273
|
-
)
|
|
271
|
+
logger.debug("No watchers found for jira_issue_id '%s'.", jira_issue_id)
|
|
274
272
|
return watchers
|
|
275
273
|
|
|
276
274
|
@staticmethod
|
|
@@ -461,5 +459,155 @@ class JiraClient:
|
|
|
461
459
|
|
|
462
460
|
return statuses_info_list
|
|
463
461
|
|
|
462
|
+
def create_postmortem_issue(
|
|
463
|
+
self,
|
|
464
|
+
project_key: str,
|
|
465
|
+
issue_type: str,
|
|
466
|
+
fields: dict[str, Any],
|
|
467
|
+
parent_issue_key: str | None = None,
|
|
468
|
+
) -> dict[str, Any]:
|
|
469
|
+
"""Create a Jira post-mortem issue with custom fields.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
project_key: Jira project key (e.g., "INCIDENT")
|
|
473
|
+
issue_type: Issue type name (e.g., "Post-mortem")
|
|
474
|
+
fields: Dictionary of field IDs to values
|
|
475
|
+
parent_issue_key: Optional parent issue key to link this post-mortem to
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Dictionary with 'key' and 'id' of created issue
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
JiraAPIError: If issue creation fails
|
|
482
|
+
"""
|
|
483
|
+
try:
|
|
484
|
+
issue_dict: dict[str, Any] = {
|
|
485
|
+
"project": {"key": project_key},
|
|
486
|
+
"issuetype": {"name": issue_type},
|
|
487
|
+
**fields,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
# Create the issue first without parent link
|
|
491
|
+
issue = self.jira.create_issue(fields=issue_dict)
|
|
492
|
+
logger.info(
|
|
493
|
+
"Created post-mortem issue %s in project %s", issue.key, project_key
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Create issue link to parent if provided
|
|
497
|
+
# Using link instead of parent to avoid hierarchy restrictions
|
|
498
|
+
if parent_issue_key:
|
|
499
|
+
self._create_issue_link_safe(
|
|
500
|
+
parent_issue_key=parent_issue_key,
|
|
501
|
+
postmortem_issue_key=issue.key,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
except exceptions.JIRAError as e:
|
|
505
|
+
logger.exception("Failed to create Jira issue in project %s", project_key)
|
|
506
|
+
error_msg = f"Failed to create Jira issue: {e.status_code} {e.text}"
|
|
507
|
+
raise JiraAPIError(error_msg) from e
|
|
508
|
+
else:
|
|
509
|
+
return {
|
|
510
|
+
"key": issue.key,
|
|
511
|
+
"id": issue.id,
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
def _create_issue_link_safe(
|
|
515
|
+
self, parent_issue_key: str, postmortem_issue_key: str
|
|
516
|
+
) -> None:
|
|
517
|
+
"""Create an issue link between parent and post-mortem, with robust error handling.
|
|
518
|
+
|
|
519
|
+
This method tries multiple link types in order of preference:
|
|
520
|
+
1. "Relates" - Standard link type
|
|
521
|
+
2. "Blocks" - Alternative link type
|
|
522
|
+
3. "Relates to" - Another common variant
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
parent_issue_key: Parent incident issue key
|
|
526
|
+
postmortem_issue_key: Post-mortem issue key
|
|
527
|
+
|
|
528
|
+
Note:
|
|
529
|
+
This method will not raise exceptions - it logs warnings instead.
|
|
530
|
+
The post-mortem creation should succeed even if linking fails.
|
|
531
|
+
"""
|
|
532
|
+
# List of link types to try, in order of preference
|
|
533
|
+
link_types = ["Relates", "Blocks", "Relates to"]
|
|
534
|
+
|
|
535
|
+
for link_type in link_types:
|
|
536
|
+
try:
|
|
537
|
+
# Validate that both issues exist before attempting link
|
|
538
|
+
try:
|
|
539
|
+
self.jira.issue(parent_issue_key)
|
|
540
|
+
self.jira.issue(postmortem_issue_key)
|
|
541
|
+
except exceptions.JIRAError as validation_error:
|
|
542
|
+
logger.warning(
|
|
543
|
+
"Issue validation failed before creating link: %s",
|
|
544
|
+
validation_error,
|
|
545
|
+
)
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
# Create the link
|
|
549
|
+
self.jira.create_issue_link(
|
|
550
|
+
type=link_type,
|
|
551
|
+
inwardIssue=parent_issue_key,
|
|
552
|
+
outwardIssue=postmortem_issue_key,
|
|
553
|
+
comment={
|
|
554
|
+
"body": f"Post-mortem {postmortem_issue_key} created for incident {parent_issue_key}"
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
except exceptions.JIRAError as link_error:
|
|
559
|
+
logger.warning(
|
|
560
|
+
"Failed to create issue link (%s) from %s to %s: %s",
|
|
561
|
+
link_type,
|
|
562
|
+
parent_issue_key,
|
|
563
|
+
postmortem_issue_key,
|
|
564
|
+
link_error,
|
|
565
|
+
)
|
|
566
|
+
# Continue to try next link type
|
|
567
|
+
else:
|
|
568
|
+
logger.info(
|
|
569
|
+
"Created issue link (%s) from %s to %s",
|
|
570
|
+
link_type,
|
|
571
|
+
parent_issue_key,
|
|
572
|
+
postmortem_issue_key,
|
|
573
|
+
)
|
|
574
|
+
return # Success - exit early
|
|
575
|
+
|
|
576
|
+
# All link types failed
|
|
577
|
+
logger.error(
|
|
578
|
+
"Failed to create any issue link from %s to %s after trying all link types: %s",
|
|
579
|
+
parent_issue_key,
|
|
580
|
+
postmortem_issue_key,
|
|
581
|
+
link_types,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def assign_issue(self, issue_key: str, account_id: str) -> bool:
|
|
585
|
+
"""Assign a Jira issue to a user.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
issue_key: Jira issue key (e.g., "INCIDENT-123")
|
|
589
|
+
account_id: Jira account ID of the user
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
True if assignment succeeded, False otherwise
|
|
593
|
+
|
|
594
|
+
Note:
|
|
595
|
+
This method does not raise exceptions. Assignment failures are logged
|
|
596
|
+
as warnings since assignment is typically an optional operation.
|
|
597
|
+
"""
|
|
598
|
+
try:
|
|
599
|
+
self.jira.assign_issue(issue_key, account_id)
|
|
600
|
+
except exceptions.JIRAError as e:
|
|
601
|
+
logger.warning(
|
|
602
|
+
"Failed to assign issue %s to user %s: %s",
|
|
603
|
+
issue_key,
|
|
604
|
+
account_id,
|
|
605
|
+
e.text if hasattr(e, "text") else str(e),
|
|
606
|
+
)
|
|
607
|
+
return False
|
|
608
|
+
else:
|
|
609
|
+
logger.info("Assigned issue %s to user %s", issue_key, account_id)
|
|
610
|
+
return True
|
|
611
|
+
|
|
464
612
|
|
|
465
613
|
client = JiraClient()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Management commands for jira_app."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Management commands for jira_app."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Generated by Django 4.2.21 on 2025-11-06 15:15
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
("incidents", "0029_add_custom_fields_to_incident"),
|
|
12
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
13
|
+
("jira_app", "0001_initial_oss"),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name="JiraPostMortem",
|
|
19
|
+
fields=[
|
|
20
|
+
(
|
|
21
|
+
"id",
|
|
22
|
+
models.BigAutoField(
|
|
23
|
+
auto_created=True,
|
|
24
|
+
primary_key=True,
|
|
25
|
+
serialize=False,
|
|
26
|
+
verbose_name="ID",
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
(
|
|
30
|
+
"jira_issue_key",
|
|
31
|
+
models.CharField(
|
|
32
|
+
help_text="Jira issue key (e.g., INCIDENT-123)",
|
|
33
|
+
max_length=32,
|
|
34
|
+
unique=True,
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"jira_issue_id",
|
|
39
|
+
models.CharField(
|
|
40
|
+
help_text="Jira issue ID", max_length=32, unique=True
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
44
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
45
|
+
(
|
|
46
|
+
"created_by",
|
|
47
|
+
models.ForeignKey(
|
|
48
|
+
blank=True,
|
|
49
|
+
null=True,
|
|
50
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
51
|
+
related_name="jira_postmortems_created",
|
|
52
|
+
to=settings.AUTH_USER_MODEL,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"incident",
|
|
57
|
+
models.OneToOneField(
|
|
58
|
+
help_text="Incident this post-mortem is for",
|
|
59
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
60
|
+
related_name="jira_postmortem_for",
|
|
61
|
+
to="incidents.incident",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
],
|
|
65
|
+
options={
|
|
66
|
+
"verbose_name": "Jira Post-mortem",
|
|
67
|
+
"verbose_name_plural": "Jira Post-mortems",
|
|
68
|
+
"db_table": "jira_postmortem",
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
]
|