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.
Files changed (50) 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/confluence/models.py +66 -6
  5. firefighter/confluence/signals/incident_updated.py +8 -26
  6. firefighter/firefighter/settings/components/jira_app.py +33 -0
  7. firefighter/incidents/admin.py +3 -0
  8. firefighter/incidents/models/impact.py +3 -5
  9. firefighter/incidents/models/incident.py +24 -9
  10. firefighter/incidents/views/views.py +2 -0
  11. firefighter/jira_app/admin.py +15 -1
  12. firefighter/jira_app/apps.py +3 -0
  13. firefighter/jira_app/client.py +151 -3
  14. firefighter/jira_app/management/__init__.py +1 -0
  15. firefighter/jira_app/management/commands/__init__.py +1 -0
  16. firefighter/jira_app/migrations/0002_add_jira_postmortem_model.py +71 -0
  17. firefighter/jira_app/models.py +50 -0
  18. firefighter/jira_app/service_postmortem.py +292 -0
  19. firefighter/jira_app/signals/__init__.py +10 -0
  20. firefighter/jira_app/signals/incident_key_events_updated.py +88 -0
  21. firefighter/jira_app/signals/postmortem_created.py +155 -0
  22. firefighter/jira_app/templates/jira/postmortem/impact.txt +12 -0
  23. firefighter/jira_app/templates/jira/postmortem/incident_summary.txt +17 -0
  24. firefighter/jira_app/templates/jira/postmortem/mitigation_actions.txt +9 -0
  25. firefighter/jira_app/templates/jira/postmortem/root_causes.txt +12 -0
  26. firefighter/jira_app/templates/jira/postmortem/timeline.txt +7 -0
  27. firefighter/raid/signals/incident_updated.py +31 -11
  28. firefighter/slack/messages/slack_messages.py +39 -3
  29. firefighter/slack/signals/postmortem_created.py +51 -3
  30. firefighter/slack/views/modals/closure_reason.py +15 -0
  31. firefighter/slack/views/modals/key_event_message.py +9 -0
  32. firefighter/slack/views/modals/postmortem.py +32 -40
  33. firefighter/slack/views/modals/update_status.py +7 -1
  34. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/METADATA +1 -1
  35. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/RECORD +50 -31
  36. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/WHEEL +1 -1
  37. firefighter_tests/test_api/test_renderer.py +41 -0
  38. firefighter_tests/test_incidents/test_models/test_incident_model.py +29 -0
  39. firefighter_tests/test_jira_app/test_incident_key_events_sync.py +112 -0
  40. firefighter_tests/test_jira_app/test_models.py +138 -0
  41. firefighter_tests/test_jira_app/test_postmortem_issue_link.py +201 -0
  42. firefighter_tests/test_jira_app/test_postmortem_service.py +416 -0
  43. firefighter_tests/test_jira_app/test_timeline_template.py +135 -0
  44. firefighter_tests/test_raid/test_raid_signals.py +50 -8
  45. firefighter_tests/test_slack/messages/test_slack_messages.py +112 -23
  46. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +18 -2
  47. firefighter_tests/test_slack/views/modals/test_key_event_message.py +30 -0
  48. firefighter_tests/test_slack/views/modals/test_update_status.py +161 -129
  49. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/entry_points.txt +0 -0
  50. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/licenses/LICENSE +0 -0
@@ -24,16 +24,36 @@ def incident_updated_close_ticket_when_mitigated_or_postmortem(
24
24
  updated_fields: list[str],
25
25
  **kwargs: Any,
26
26
  ) -> None:
27
- # Close Jira ticket if mitigated, postmortem, or closed
28
- if "_status" in updated_fields and incident_update.status in {
29
- IncidentStatus.MITIGATED,
30
- IncidentStatus.POST_MORTEM,
31
- IncidentStatus.CLOSED,
32
- }:
33
- if not hasattr(incident, "jira_ticket") or incident.jira_ticket is None:
34
- logger.warning(
35
- f"Trying to close Jira ticket for incident {incident.id} but not having"
36
- )
37
- return
27
+ """Close Jira incident ticket based on incident status and priority.
28
+
29
+ Closure logic:
30
+ - P1/P2 (needs_postmortem): Close only when incident is CLOSED
31
+ - P3+ (no postmortem): Close when incident is MITIGATED or CLOSED
32
+ - POST_MORTEM status never closes the ticket (it remains open during PM phase)
33
+ """
34
+ if "_status" not in updated_fields:
35
+ return
36
+
37
+ if not hasattr(incident, "jira_ticket") or incident.jira_ticket is None:
38
+ logger.warning(
39
+ f"Trying to close Jira ticket for incident {incident.id} but no Jira ticket found"
40
+ )
41
+ return
42
+
43
+ # Determine if we should close the ticket based on status and priority
44
+ should_close = False
45
+
46
+ if incident_update.status == IncidentStatus.CLOSED:
47
+ # Always close on CLOSED regardless of priority
48
+ should_close = True
49
+ elif incident_update.status == IncidentStatus.MITIGATED:
50
+ # Only close on MITIGATED if incident doesn't need postmortem (P3+)
51
+ should_close = not incident.needs_postmortem
52
+
53
+ # POST_MORTEM status never closes the ticket - it stays open during PM phase
54
+
55
+ if should_close:
56
+ status_label = incident_update.status.label if incident_update.status else "Unknown"
57
+ logger.info(f"Closing Jira ticket for incident {incident.id} (status: {status_label})")
38
58
  client.close_issue(issue_id=incident.jira_ticket.id)
39
59
  # XXX We may want to add a comment if there is an incident update message on close
@@ -208,6 +208,7 @@ class SlackMessageIncidentDeclaredAnnouncement(SlackMessageSurface):
208
208
  return f"A new {self.incident.priority} incident has been declared: {self.incident.title}"
209
209
 
210
210
  def get_blocks(self) -> list[Block]:
211
+ # Build main fields (keep under 10 items due to Slack limit)
211
212
  fields = [
212
213
  f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
213
214
  f":package: *Incident category:* {self.incident.incident_category.name}",
@@ -218,7 +219,7 @@ class SlackMessageIncidentDeclaredAnnouncement(SlackMessageSurface):
218
219
  if hasattr(self.incident, "jira_ticket") and self.incident.jira_ticket:
219
220
  fields.append(f":jira_new: <{self.incident.jira_ticket.url}|*Jira ticket*>")
220
221
 
221
- # Add custom fields if present
222
+ # Add custom fields if present (max to avoid exceeding 10 fields limit)
222
223
  if hasattr(self.incident, "custom_fields") and self.incident.custom_fields:
223
224
  custom_fields = self.incident.custom_fields
224
225
  if custom_fields.get("zendesk_ticket_id"):
@@ -240,17 +241,36 @@ class SlackMessageIncidentDeclaredAnnouncement(SlackMessageSurface):
240
241
  slack_block_quote(self.incident.description),
241
242
  DividerBlock(),
242
243
  SectionBlock(
243
- fields=fields,
244
+ fields=fields[:10], # Slack limits fields to 10 items max
244
245
  accessory=ButtonElement(
245
246
  text="Update",
246
247
  value=str(self.incident.id),
247
248
  action_id=UpdateModal.open_action,
248
249
  ),
249
250
  ),
251
+ *self._postmortem_blocks(),
250
252
  *self._impact_blocks(),
251
253
  ]
252
254
  return blocks
253
255
 
256
+ def _postmortem_blocks(self) -> list[Block]:
257
+ """Build post-mortem links block if any PM exists."""
258
+ pm_fields = []
259
+
260
+ if hasattr(self.incident, "postmortem_for") and self.incident.postmortem_for:
261
+ pm_fields.append(f":confluence: <{self.incident.postmortem_for.page_url}|*Confluence Post-mortem*>")
262
+
263
+ if hasattr(self.incident, "jira_postmortem_for") and self.incident.jira_postmortem_for:
264
+ pm_fields.append(f":jira_new: <{self.incident.jira_postmortem_for.issue_url}|*Jira Post-mortem ({self.incident.jira_postmortem_for.jira_issue_key})*>")
265
+
266
+ if not pm_fields:
267
+ return []
268
+
269
+ return [
270
+ DividerBlock(),
271
+ SectionBlock(fields=pm_fields),
272
+ ]
273
+
254
274
  def _impact_blocks(self) -> list[Block]:
255
275
  impacts = self.incident.impacts.all().order_by("impact_level__value")[:10]
256
276
  none_level = LevelChoices.NONE.value
@@ -573,7 +593,23 @@ class SlackMessageIncidentPostMortemCreated(SlackMessageSurface):
573
593
  super().__init__()
574
594
 
575
595
  def get_text(self) -> str:
576
- return f"📔 The post-mortem has been created, you can edit it here: {self.incident.postmortem_for.page_url}."
596
+ """Generate text with links to all available post-mortems."""
597
+ parts = ["📔 The post-mortem has been created:"]
598
+
599
+ # Add Confluence link if available
600
+ if hasattr(self.incident, "postmortem_for"):
601
+ parts.append(
602
+ f"• Confluence: {self.incident.postmortem_for.page_url}"
603
+ )
604
+
605
+ # Add Jira link if available
606
+ if hasattr(self.incident, "jira_postmortem_for"):
607
+ jira_pm = self.incident.jira_postmortem_for
608
+ parts.append(
609
+ f"• Jira: {jira_pm.issue_url} ({jira_pm.jira_issue_key})"
610
+ )
611
+
612
+ return "\n".join(parts)
577
613
 
578
614
  def get_blocks(self) -> list[Block]:
579
615
  return [SectionBlock(text=self.get_text())]
@@ -4,6 +4,7 @@ import logging
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from django.dispatch.dispatcher import receiver
7
+ from slack_sdk.errors import SlackApiError
7
8
 
8
9
  from firefighter.incidents.signals import postmortem_created
9
10
  from firefighter.slack.messages.slack_messages import (
@@ -19,7 +20,19 @@ logger = logging.getLogger(__name__)
19
20
  @receiver(signal=postmortem_created)
20
21
  # pylint: disable=unused-argument
21
22
  def postmortem_created_send(sender: Any, incident: Incident, **kwargs: Any) -> None:
22
- if not hasattr(incident, "postmortem_for"):
23
+ # Refresh incident from database to get the newly created post-mortem relationships
24
+ # This is necessary because the signal might be sent before the ORM cache is updated
25
+ try:
26
+ incident.refresh_from_db()
27
+ except Exception:
28
+ logger.exception(f"Failed to refresh incident #{incident.id} from database")
29
+ return
30
+
31
+ # Check if at least one post-mortem exists
32
+ has_confluence = hasattr(incident, "postmortem_for")
33
+ has_jira = hasattr(incident, "jira_postmortem_for")
34
+
35
+ if not has_confluence and not has_jira:
23
36
  logger.warning(f"No PostMortem to post for incident {incident}.")
24
37
  return
25
38
 
@@ -28,9 +41,44 @@ def postmortem_created_send(sender: Any, incident: Incident, **kwargs: Any) -> N
28
41
  f"No Incident Slack channel to post PostMortem for incident {incident}."
29
42
  )
30
43
  return
44
+
45
+ # Send message with all available post-mortem links (pinned)
31
46
  incident.conversation.send_message_and_save(
32
47
  SlackMessageIncidentPostMortemCreated(incident), pin=True
33
48
  )
34
- incident.conversation.add_bookmark(
35
- title="Postmortem", link=incident.postmortem_for.page_url, emoji=":confluence:"
49
+
50
+ # Update the initial incident message with post-mortem links
51
+ from firefighter.slack.messages.slack_messages import ( # noqa: PLC0415
52
+ SlackMessageIncidentDeclaredAnnouncement,
53
+ )
54
+
55
+ incident.conversation.send_message_and_save(
56
+ SlackMessageIncidentDeclaredAnnouncement(incident)
36
57
  )
58
+
59
+ # Add bookmarks for each available post-mortem
60
+ if has_confluence:
61
+ try:
62
+ incident.conversation.add_bookmark(
63
+ title="Postmortem (Confluence)",
64
+ link=incident.postmortem_for.page_url,
65
+ emoji=":confluence:",
66
+ )
67
+ except SlackApiError as e:
68
+ logger.warning(
69
+ f"Failed to add Confluence bookmark for incident #{incident.id}: {e}. "
70
+ "This is expected in test environments without custom emojis."
71
+ )
72
+
73
+ if has_jira:
74
+ try:
75
+ incident.conversation.add_bookmark(
76
+ title=f"Postmortem ({incident.jira_postmortem_for.jira_issue_key})",
77
+ link=incident.jira_postmortem_for.issue_url,
78
+ emoji=":jira_new:",
79
+ )
80
+ except SlackApiError as e:
81
+ logger.warning(
82
+ f"Failed to add Jira bookmark for incident #{incident.id}: {e}. "
83
+ "This is expected in test environments without custom emojis."
84
+ )
@@ -115,6 +115,21 @@ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
115
115
  self, ack: Ack, body: dict[str, Any], incident: Incident, user: User
116
116
  ) -> bool | None:
117
117
  """Handle the closure reason modal submission."""
118
+ # Early validation: Check if incident can be closed BEFORE calling ack()
119
+ # This validation happens BEFORE ack() so we can display errors in the modal
120
+ can_close, reasons = incident.can_be_closed
121
+ if not can_close:
122
+ # Build error message from reasons
123
+ error_messages = [reason[1] for reason in reasons]
124
+ error_text = "\n".join([f"• {msg}" for msg in error_messages])
125
+ ack(
126
+ response_action="errors",
127
+ errors={
128
+ "closure_message": f"Cannot close this incident:\n{error_text}"
129
+ }
130
+ )
131
+ return False
132
+
118
133
  # Clear ALL modals in the stack (not just this one)
119
134
  # This ensures the underlying "Update Status" modal is also closed
120
135
  ack(response_action="clear")
@@ -12,6 +12,7 @@ from slack_sdk.models.blocks.basic_components import MarkdownTextObject
12
12
  from slack_sdk.models.blocks.block_elements import ButtonElement
13
13
 
14
14
  from firefighter.incidents.forms.update_key_events import IncidentUpdateKeyEventsForm
15
+ from firefighter.incidents.signals import incident_key_events_updated
15
16
  from firefighter.slack.messages.base import SlackMessageStrategy, SlackMessageSurface
16
17
  from firefighter.slack.views.modals.base_modal.base import MessageForm
17
18
 
@@ -108,6 +109,14 @@ class KeyEvents(MessageForm[IncidentUpdateKeyEventsForm]):
108
109
  return
109
110
  self.form = form
110
111
  self.form.save()
112
+
113
+ # Send signal to update Jira post-mortem timeline if applicable
114
+ logger.debug("Sending signal incident_key_events_updated")
115
+ incident_key_events_updated.send_robust(
116
+ __name__,
117
+ incident=incident,
118
+ )
119
+
111
120
  incident.compute_metrics()
112
121
 
113
122
  self.update_with_form()
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import logging
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
- from django.apps import apps
7
6
  from slack_sdk.models.blocks.blocks import Block, SectionBlock
8
7
  from slack_sdk.models.views import View
9
8
 
@@ -12,9 +11,6 @@ from firefighter.slack.views.modals.base_modal.mixins import (
12
11
  IncidentSelectableModalMixin,
13
12
  )
14
13
 
15
- if apps.is_installed("firefighter.confluence"):
16
- from firefighter.confluence.models import PostMortem
17
-
18
14
  if TYPE_CHECKING:
19
15
  from slack_bolt.context.ack.ack import Ack
20
16
 
@@ -34,54 +30,50 @@ class PostMortemModal(
34
30
 
35
31
  def build_modal_fn(self, incident: Incident, **kwargs: Any) -> View:
36
32
  blocks: list[Block] = []
37
- if hasattr(incident, "postmortem_for"):
38
- blocks.extend([
39
- SectionBlock(
40
- text=f"Postmortem for incident #{incident.id} has already been created."
41
- ),
42
- SectionBlock(
43
- text=f"See the postmortem page <{incident.postmortem_for.page_url}|on Confluence>."
44
- ),
45
- ])
46
- submit = None
33
+
34
+ # Check existing post-mortems
35
+ has_confluence = hasattr(incident, "postmortem_for")
36
+ has_jira = hasattr(incident, "jira_postmortem_for")
37
+
38
+ if has_confluence or has_jira:
39
+ blocks.append(
40
+ SectionBlock(text=f"Post-mortem(s) for incident #{incident.id}:")
41
+ )
42
+
43
+ if has_confluence:
44
+ blocks.append(
45
+ SectionBlock(
46
+ text=f"• Confluence: <{incident.postmortem_for.page_url}|View page>"
47
+ )
48
+ )
49
+
50
+ if has_jira:
51
+ blocks.append(
52
+ SectionBlock(
53
+ text=f"• Jira: <{incident.jira_postmortem_for.issue_url}|{incident.jira_postmortem_for.jira_issue_key}>"
54
+ )
55
+ )
47
56
  else:
48
- blocks.extend([
57
+ blocks.append(
49
58
  SectionBlock(
50
- text=f"Postmortem does not yet exist for incident #{incident.id}."
51
- ),
52
- SectionBlock(
53
- text="Click on the button to create the postmortem on Confluence."
54
- ),
55
- ])
56
- submit = "Create postmortem"[:24]
59
+ text=f"Post-mortem for incident #{incident.id} will be automatically created when the incident reaches MITIGATED status."
60
+ )
61
+ )
57
62
 
58
63
  return View(
59
64
  type="modal",
60
65
  title="Postmortem"[:24],
61
- submit=submit,
66
+ submit=None,
62
67
  callback_id=self.callback_id,
63
68
  private_metadata=str(incident.id),
64
69
  blocks=blocks,
65
70
  )
66
71
 
67
72
  @staticmethod
68
- def handle_modal_fn(ack: Ack, body: dict[str, Any], incident: Incident) -> None: # type: ignore[override]
69
- if not apps.is_installed("firefighter.confluence"):
70
- ack(text="Confluence is not enabled!")
71
- return
72
- if hasattr(incident, "postmortem_for"):
73
- ack(text="Post-mortem has already been created.")
74
- return
75
-
76
- # Check if this modal was pushed on top of another modal
77
- # If yes, clear the entire stack to avoid leaving stale modals visible
78
- is_pushed = body.get("view", {}).get("previous_view_id") is not None
79
- if is_pushed:
80
- ack(response_action="clear")
81
- else:
82
- ack()
83
-
84
- PostMortem.objects.create_postmortem_for_incident(incident)
73
+ def handle_modal_fn(ack: Ack, **_kwargs: Any) -> None:
74
+ # This modal is now read-only (no submit button)
75
+ # Post-mortems are created automatically when incident reaches MITIGATED status
76
+ ack()
85
77
 
86
78
 
87
79
  modal_postmortem = PostMortemModal()
@@ -104,12 +104,14 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
104
104
  },
105
105
  "incident": incident,
106
106
  },
107
+ ack_on_success=False, # We'll ack after custom validations
107
108
  )
108
109
  if slack_form is None:
109
110
  return
110
111
  form: UpdateStatusFormSlack = slack_form.form
111
112
  if len(form.cleaned_data) == 0:
112
113
  # XXX We should have a prompt for empty forms
114
+ ack()
113
115
  return
114
116
 
115
117
  # Check if user is trying to close and needs a closure reason
@@ -118,13 +120,14 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
118
120
  if handle_update_status_close_request(ack, body, incident, target_status):
119
121
  return
120
122
 
121
- # If trying to close, validate that incident can be closed
123
+ # Validate that incident can be closed (check key events, post-mortem, etc.)
122
124
  if target_status == IncidentStatus.CLOSED:
123
125
  can_close, reasons = incident.can_be_closed
124
126
  if not can_close:
125
127
  # Build error message from reasons
126
128
  error_messages = [reason[1] for reason in reasons]
127
129
  error_text = "\n".join([f"• {msg}" for msg in error_messages])
130
+ logger.warning(f"Cannot close incident #{incident.id} via Update Status: {error_text}")
128
131
  ack(
129
132
  response_action="errors",
130
133
  errors={
@@ -133,6 +136,9 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
133
136
  )
134
137
  return
135
138
 
139
+ # All validations passed, acknowledge the submission
140
+ ack()
141
+
136
142
  update_kwargs: dict[str, Any] = {}
137
143
  for changed_key in form.changed_data:
138
144
  if changed_key in {"incident_category", "priority"}:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firefighter-incident
3
- Version: 0.0.22
3
+ Version: 0.0.23
4
4
  Summary: Incident Management tool made for Slack using Django
5
5
  Project-URL: Repository, https://github.com/ManoManoTech/firefighter-incident
6
6
  Project-URL: Documentation, https://manomanotech.github.io/firefighter-incident/latest/