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.
Files changed (52) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/api/serializers.py +18 -0
  3. firefighter/api/views/incidents.py +3 -0
  4. firefighter/components/avatar/avatar.html +2 -2
  5. firefighter/components/avatar/avatar.py +2 -2
  6. firefighter/confluence/models.py +66 -6
  7. firefighter/confluence/signals/incident_updated.py +8 -26
  8. firefighter/firefighter/settings/components/jira_app.py +33 -0
  9. firefighter/incidents/admin.py +3 -0
  10. firefighter/incidents/models/impact.py +3 -5
  11. firefighter/incidents/models/incident.py +24 -9
  12. firefighter/incidents/views/views.py +2 -0
  13. firefighter/jira_app/admin.py +15 -1
  14. firefighter/jira_app/apps.py +3 -0
  15. firefighter/jira_app/client.py +151 -3
  16. firefighter/jira_app/management/__init__.py +1 -0
  17. firefighter/jira_app/management/commands/__init__.py +1 -0
  18. firefighter/jira_app/migrations/0002_add_jira_postmortem_model.py +71 -0
  19. firefighter/jira_app/models.py +50 -0
  20. firefighter/jira_app/service_postmortem.py +292 -0
  21. firefighter/jira_app/signals/__init__.py +10 -0
  22. firefighter/jira_app/signals/incident_key_events_updated.py +88 -0
  23. firefighter/jira_app/signals/postmortem_created.py +155 -0
  24. firefighter/jira_app/templates/jira/postmortem/impact.txt +12 -0
  25. firefighter/jira_app/templates/jira/postmortem/incident_summary.txt +17 -0
  26. firefighter/jira_app/templates/jira/postmortem/mitigation_actions.txt +9 -0
  27. firefighter/jira_app/templates/jira/postmortem/root_causes.txt +12 -0
  28. firefighter/jira_app/templates/jira/postmortem/timeline.txt +7 -0
  29. firefighter/raid/signals/incident_updated.py +31 -11
  30. firefighter/slack/messages/slack_messages.py +39 -3
  31. firefighter/slack/signals/postmortem_created.py +51 -3
  32. firefighter/slack/views/modals/closure_reason.py +15 -0
  33. firefighter/slack/views/modals/key_event_message.py +9 -0
  34. firefighter/slack/views/modals/postmortem.py +32 -40
  35. firefighter/slack/views/modals/update_status.py +7 -1
  36. {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/METADATA +2 -1
  37. {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/RECORD +52 -33
  38. {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/WHEEL +1 -1
  39. firefighter_tests/test_api/test_renderer.py +41 -0
  40. firefighter_tests/test_incidents/test_models/test_incident_model.py +29 -0
  41. firefighter_tests/test_jira_app/test_incident_key_events_sync.py +112 -0
  42. firefighter_tests/test_jira_app/test_models.py +138 -0
  43. firefighter_tests/test_jira_app/test_postmortem_issue_link.py +201 -0
  44. firefighter_tests/test_jira_app/test_postmortem_service.py +416 -0
  45. firefighter_tests/test_jira_app/test_timeline_template.py +135 -0
  46. firefighter_tests/test_raid/test_raid_signals.py +50 -8
  47. firefighter_tests/test_slack/messages/test_slack_messages.py +112 -23
  48. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +18 -2
  49. firefighter_tests/test_slack/views/modals/test_key_event_message.py +30 -0
  50. firefighter_tests/test_slack/views/modals/test_update_status.py +161 -133
  51. {firefighter_incident-0.0.21.dist-info → firefighter_incident-0.0.23.dist-info}/entry_points.txt +0 -0
  52. {firefighter_incident-0.0.21.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.21'
32
- __version_tuple__ = version_tuple = (0, 0, 21)
31
+ __version__ = version = '0.0.23'
32
+ __version_tuple__ = version_tuple = (0, 0, 23)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -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",
@@ -1,6 +1,6 @@
1
- {% if user.avatar %}
1
+ {% if avatar_user.avatar %}
2
2
  <span class="rounded-full block overflow-hidden z-[1]">
3
- <img src="{{ user.avatar }}" alt="{{ user.full_name }}'s avatar" height="{{ size_px }}" width="{{ size_px }}" onerror="this.style='display: none;'">
3
+ <img src="{{ avatar_user.avatar }}" alt="{{ avatar_user.full_name }}'s avatar" height="{{ size_px }}" width="{{ size_px }}" onerror="this.style='display: none;'">
4
4
  </span>
5
5
  {% endif %}
6
6
  {% comment %} Always display the fallback, as we don't control external CDN expiration {% endcomment %}
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
11
11
 
12
12
 
13
13
  class Data(TypedDict):
14
- user: User
14
+ avatar_user: User
15
15
  size_tailwind: int
16
16
  size_px: int
17
17
 
@@ -31,7 +31,7 @@ class Avatar(component.Component):
31
31
  def get_context_data(self, user: User, **kwargs: Any) -> Data:
32
32
  size_px, size_tailwind = (40, 10) if kwargs.get("size") == "md" else (80, 20)
33
33
  return {
34
- "user": user,
34
+ "avatar_user": user,
35
35
  "size_tailwind": size_tailwind,
36
36
  "size_px": size_px,
37
37
  }
@@ -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(incident: Incident) -> PostMortem:
28
- if hasattr(incident, "postmortem_for"):
29
- raise ValueError("Incident already has a post-mortem page")
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
- logger.info("Creating PostMortem for %s", incident)
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
- if sender == "update_status":
38
- # Post postmortem reminder if needed
39
- if (
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
+ }
@@ -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 [call-overload]
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 [call-overload]
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: ManyToManyField[Impact, Any]
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
- return (
342
- bool(
343
- self.priority
344
- and self.environment
345
- and self.priority.needs_postmortem
346
- and self.environment.value == "PRD"
347
- )
348
- if apps.is_installed("firefighter.confluence")
349
- else False
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",
@@ -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
@@ -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()
@@ -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
+ ]