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.
Files changed (30) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/confluence/models.py +16 -1
  3. firefighter/incidents/management/__init__.py +1 -0
  4. firefighter/incidents/management/commands/__init__.py +1 -0
  5. firefighter/incidents/management/commands/backdate_incident_mitigated.py +94 -0
  6. firefighter/incidents/management/commands/test_postmortem_reminders.py +113 -0
  7. firefighter/incidents/migrations/0030_add_mitigated_at_field.py +22 -0
  8. firefighter/incidents/models/incident.py +43 -8
  9. firefighter/jira_app/service_postmortem.py +18 -4
  10. firefighter/jira_app/signals/postmortem_created.py +108 -46
  11. firefighter/slack/messages/slack_messages.py +162 -0
  12. firefighter/slack/migrations/0009_add_postmortem_reminder_periodic_task.py +60 -0
  13. firefighter/slack/rules.py +22 -0
  14. firefighter/slack/tasks/send_postmortem_reminders.py +127 -0
  15. firefighter/slack/views/modals/close.py +113 -3
  16. firefighter/slack/views/modals/closure_reason.py +54 -25
  17. firefighter/slack/views/modals/update_status.py +4 -4
  18. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/METADATA +1 -1
  19. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/RECORD +30 -23
  20. firefighter_tests/test_incidents/test_incident_urls.py +4 -0
  21. firefighter_tests/test_incidents/test_models/test_incident_model.py +109 -1
  22. firefighter_tests/test_incidents/test_views/test_incident_detail_view.py +4 -0
  23. firefighter_tests/test_jira_app/test_postmortem_service.py +2 -2
  24. firefighter_tests/test_slack/messages/test_slack_messages.py +4 -0
  25. firefighter_tests/test_slack/views/modals/test_close.py +4 -0
  26. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +109 -26
  27. firefighter_tests/test_slack/views/modals/test_update_status.py +45 -51
  28. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/WHEEL +0 -0
  29. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/entry_points.txt +0 -0
  30. {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.25'
32
- __version_tuple__ = version_tuple = (0, 0, 25)
31
+ __version__ = version = '0.0.27'
32
+ __version_tuple__ = version_tuple = (0, 0, 27)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -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
- IncidentCategory, on_delete=models.PROTECT
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(self, "incident_category_id", incident_category_id, updated_fields)
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(), field_name="incident_category__group_id", label="Group"
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
- - Platform (customfield_10201)
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
- platform = custom_fields.get("platform")
268
- if platform:
269
- # Remove "platform-" prefix if present
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
- from firefighter.jira_app.service_postmortem import ( # noqa: PLC0415
25
- jira_postmortem_service,
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
- from firefighter.confluence.models import PostMortemManager # noqa: PLC0415
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
- return PostMortemManager
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
- from firefighter.slack.tasks.reminder_postmortem import ( # noqa: PLC0415
66
- publish_fixed_next_actions,
67
- publish_postmortem_reminder,
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
- # Check and create Confluence post-mortem
196
+ # Create Confluence post-mortem if enabled
110
197
  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
198
+ confluence_pm = _create_confluence_postmortem(incident)
199
+
200
+ # Create Jira post-mortem if enabled
129
201
  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
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
- from firefighter.incidents.signals import postmortem_created # noqa: PLC0415
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)