firefighter-incident 0.0.35__py3-none-any.whl → 0.0.37__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.
@@ -102,7 +102,7 @@
102
102
  <div class="lg:col-start-8 lg:col-span-5 space-y-6 ">
103
103
  {% component "card" card_title="External resources" id="incident-integrations" %}
104
104
  {% fill "card_content" %}
105
- {% if not incident.conversation and not incident.postmortem_for and pagerduty_incident_set.count == 0 %}
105
+ {% if not incident.conversation and not has_confluence_pm and not has_jira_pm and not has_jira_ticket and pagerduty_incident_set.count == 0 %}
106
106
  <div class="px-4 py-5 sm:px-6 col-span-2">
107
107
  <h4 class="text-center font-medium text-sm text-neutral-500 dark:text-neutral-100">
108
108
  No external resources for this incident.
@@ -144,7 +144,7 @@
144
144
  </div>
145
145
  </li>
146
146
  {% endif %}
147
- {% if incident.postmortem_for %}
147
+ {% if has_confluence_app and incident.postmortem_for %}
148
148
  <li class="py-4 flex items-center">
149
149
  <div class="h-10 w-10 flex shrink-0">
150
150
  <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 m-auto" viewBox="0 0 20 20" fill="currentColor">
@@ -157,6 +157,19 @@
157
157
  </div>
158
158
  </li>
159
159
  {% endif %}
160
+ {% if has_jira_app and incident.jira_postmortem_for %}
161
+ <li class="py-4 flex items-center">
162
+ <div class="h-10 w-10 flex shrink-0">
163
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 m-auto" viewBox="0 0 20 20" fill="currentColor">
164
+ <path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd" />
165
+ </svg>
166
+ </div>
167
+ <div class="ml-3">
168
+ <p class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Post-mortem</p>
169
+ <p class="text-sm text-neutral-500 dark:text-neutral-300"> <a href="{{ incident.jira_postmortem_for.issue_url }}" target="_blank" rel="noopener noreferrer" class="underline" >{{ incident.jira_postmortem_for.jira_issue_key|truncatechars:70 }}</a></p>
170
+ </div>
171
+ </li>
172
+ {% endif %}
160
173
  {% if incident.jira_ticket %}
161
174
  <li class="py-4 flex items-center">
162
175
  <div class="h-10 w-10 flex shrink-0">
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import re
5
+ from contextlib import suppress
5
6
  from typing import TYPE_CHECKING, Any, cast
6
7
 
7
8
  from django.conf import settings
@@ -194,8 +195,35 @@ class IncidentDetailView(CustomDetailView[Incident]):
194
195
 
195
196
  incident: Incident = context["incident"]
196
197
 
198
+ # Check if external resources exist
199
+ has_confluence_app = "firefighter.confluence" in settings.INSTALLED_APPS
200
+ has_jira_app = "firefighter.jira_app" in settings.INSTALLED_APPS
201
+
202
+ # Safely check for post-mortem existence
203
+ # We suppress exceptions because accessing OneToOne relations can fail
204
+ # in various ways if the related app is not installed or tables don't exist
205
+ has_confluence_pm = False
206
+ if has_confluence_app:
207
+ with suppress(AttributeError, ImportError, LookupError):
208
+ has_confluence_pm = bool(incident.postmortem_for)
209
+
210
+ has_jira_pm = False
211
+ if has_jira_app:
212
+ with suppress(AttributeError, ImportError, LookupError):
213
+ has_jira_pm = bool(incident.jira_postmortem_for)
214
+
215
+ has_jira_ticket = False
216
+ if has_jira_app:
217
+ with suppress(AttributeError, ImportError, LookupError):
218
+ has_jira_ticket = bool(incident.jira_ticket)
219
+
197
220
  additional_context = {
198
221
  "page_title": f"Incident #{incident.id}",
222
+ "has_confluence_app": has_confluence_app,
223
+ "has_jira_app": has_jira_app,
224
+ "has_confluence_pm": has_confluence_pm,
225
+ "has_jira_pm": has_jira_pm,
226
+ "has_jira_ticket": has_jira_ticket,
199
227
  }
200
228
 
201
229
  return {**context, **additional_context}
@@ -126,6 +126,11 @@ class RaidJiraClient(JiraClient):
126
126
  def get_projects(self) -> list[Project]:
127
127
  return self.jira.projects()
128
128
 
129
+ def update_issue_fields(self, issue_id: str | int, **fields: Any) -> None:
130
+ """Update Jira issue fields (supports custom fields like customfield_11064)."""
131
+ issue = self.jira.issue(str(issue_id))
132
+ issue.update(fields=fields)
133
+
129
134
  @staticmethod
130
135
  def add_attachments_to_issue(issue_id: str | int, urls: list[str]) -> None:
131
136
  """Add attachments to a Jira issue.
@@ -4,14 +4,18 @@ import logging
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from django.conf import settings
7
+ from django.core.cache import cache
7
8
  from rest_framework import serializers
8
9
 
10
+ from firefighter.incidents.enums import IncidentStatus
11
+ from firefighter.incidents.models.priority import Priority
9
12
  from firefighter.incidents.models.user import User
10
13
  from firefighter.jira_app.client import (
11
14
  JiraAPIError,
12
15
  JiraUserNotFoundError,
13
16
  SlackNotificationError,
14
17
  )
18
+ from firefighter.jira_app.service_postmortem import jira_postmortem_service
15
19
  from firefighter.raid.client import client as jira_client
16
20
  from firefighter.raid.forms import (
17
21
  alert_slack_comment_ticket,
@@ -19,14 +23,35 @@ from firefighter.raid.forms import (
19
23
  alert_slack_update_ticket,
20
24
  )
21
25
  from firefighter.raid.models import JiraTicket
22
- from firefighter.raid.utils import get_domain_from_email
26
+ from firefighter.raid.utils import get_domain_from_email, normalize_cache_value
23
27
  from firefighter.slack.models.user import SlackUser
24
28
 
25
29
  if TYPE_CHECKING:
30
+ from firefighter.incidents.models.incident import Incident
26
31
  from firefighter.jira_app.models import JiraUser
27
32
 
28
33
 
29
34
  JIRA_USER_IDS: dict[str, str] = settings.RAID_JIRA_USER_IDS
35
+ JIRA_TO_IMPACT_STATUS_MAP: dict[str, IncidentStatus] = {
36
+ "Incoming": IncidentStatus.OPEN,
37
+ "Pending resolution": IncidentStatus.OPEN,
38
+ "in progress": IncidentStatus.MITIGATING,
39
+ "Reporter validation": IncidentStatus.MITIGATED,
40
+ "Closed": IncidentStatus.CLOSED,
41
+ }
42
+ JIRA_TO_IMPACT_PRIORITY_MAP: dict[str, int] = {
43
+ "1": 1,
44
+ "2": 2,
45
+ "3": 3,
46
+ "4": 4,
47
+ "5": 5,
48
+ # Legacy Jira names still supported
49
+ "Highest": 1,
50
+ "High": 2,
51
+ "Medium": 3,
52
+ "Low": 4,
53
+ "Lowest": 5,
54
+ }
30
55
 
31
56
  logger = logging.getLogger(__name__)
32
57
 
@@ -138,8 +163,11 @@ class LandbotIssueRequestSerializer(serializers.ModelSerializer[JiraTicket]):
138
163
  default="SBI",
139
164
  )
140
165
  priority = serializers.IntegerField(
141
- min_value=1, max_value=5, write_only=True, allow_null=True,
142
- help_text="Priority level 1-5 (1=Critical, 2=High, 3=Medium, 4=Low, 5=Lowest)"
166
+ min_value=1,
167
+ max_value=5,
168
+ write_only=True,
169
+ allow_null=True,
170
+ help_text="Priority level 1-5 (1=Critical, 2=High, 3=Medium, 4=Low, 5=Lowest)",
143
171
  )
144
172
  business_impact = serializers.ChoiceField(
145
173
  write_only=True, choices=["High", "Medium", "Low"], allow_null=True
@@ -279,34 +307,291 @@ class JiraWebhookUpdateSerializer(serializers.Serializer[Any]):
279
307
  )
280
308
 
281
309
  def create(self, validated_data: dict[str, Any]) -> bool:
282
- jira_field_modified = validated_data["changelog"].get("items")[0].get("field")
283
- if jira_field_modified in {
284
- "Priority",
285
- "project",
286
- "description",
287
- "status",
288
- }:
289
- jira_ticket_key = validated_data["issue"].get("key")
290
- status = alert_slack_update_ticket(
291
- jira_ticket_id=validated_data["issue"].get("id"),
292
- jira_ticket_key=jira_ticket_key,
293
- jira_author_name=validated_data["user"].get("displayName"),
294
- jira_field_modified=jira_field_modified,
295
- jira_field_from=validated_data["changelog"]
296
- .get("items")[0]
297
- .get("fromString"),
298
- jira_field_to=validated_data["changelog"]
299
- .get("items")[0]
300
- .get("toString"),
301
- )
302
- if status is not True:
303
- logger.error(
304
- f"Could not alert in Slack for the update/s in the Jira ticket {jira_ticket_key}"
310
+ changes = validated_data.get("changelog", {}).get("items") or []
311
+ if not changes:
312
+ logger.debug("Jira webhook had no changelog items; skipping.")
313
+ return True
314
+
315
+ jira_ticket_key = validated_data["issue"].get("key")
316
+ incident = (
317
+ self._get_incident_from_jira_ticket(jira_ticket_key)
318
+ if jira_ticket_key
319
+ else None
320
+ )
321
+ if incident is None:
322
+ # No linked incident: still emit Slack alerts for tracked fields (status/priority).
323
+ for change_item in changes:
324
+ field = (change_item.get("field") or "").lower()
325
+ to_val = change_item.get("toString")
326
+ from_val = change_item.get("fromString")
327
+ is_status = field == "status"
328
+ is_priority = (
329
+ self._parse_priority_value(to_val) is not None
330
+ or self._parse_priority_value(from_val) is not None
305
331
  )
332
+ if not (is_status or is_priority):
333
+ continue
334
+ if not self._alert_slack_update(
335
+ validated_data, jira_ticket_key, change_item
336
+ ):
337
+ raise SlackNotificationError("Could not alert in Slack")
338
+ return True
339
+
340
+ for change_item in changes:
341
+ if not self._sync_jira_fields_to_incident(
342
+ validated_data, jira_ticket_key, incident, change_item
343
+ ):
344
+ continue
345
+
346
+ return True
347
+
348
+ @staticmethod
349
+ def _alert_slack_update(
350
+ validated_data: dict[str, Any],
351
+ jira_ticket_key: str | None,
352
+ change_item: dict[str, Any],
353
+ ) -> bool:
354
+ ticket_id = validated_data["issue"].get("id")
355
+ ticket_key = jira_ticket_key or ""
356
+ jira_author_name = str(validated_data["user"].get("displayName") or "")
357
+ jira_field_modified = str(change_item.get("field") or "")
358
+ jira_field_from = str(change_item.get("fromString") or "")
359
+ jira_field_to = str(change_item.get("toString") or "")
360
+
361
+ if ticket_id is None or ticket_key == "" or jira_field_modified == "":
362
+ logger.error(
363
+ "Missing required Jira changelog data: id=%s key=%s field=%s",
364
+ ticket_id,
365
+ jira_ticket_key,
366
+ change_item.get("field"),
367
+ )
368
+ return False
369
+
370
+ status = alert_slack_update_ticket(
371
+ jira_ticket_id=int(ticket_id),
372
+ jira_ticket_key=ticket_key,
373
+ jira_author_name=jira_author_name,
374
+ jira_field_modified=jira_field_modified,
375
+ jira_field_from=jira_field_from,
376
+ jira_field_to=jira_field_to,
377
+ )
378
+ if status is not True:
379
+ logger.error(
380
+ "Could not alert in Slack for the update/s in the Jira ticket %s",
381
+ jira_ticket_key or "unknown",
382
+ )
383
+ return False
384
+ return True
385
+
386
+ def _sync_jira_fields_to_incident(
387
+ self,
388
+ validated_data: dict[str, Any],
389
+ jira_ticket_key: str | None,
390
+ incident: Incident,
391
+ change_item: dict[str, Any],
392
+ ) -> bool:
393
+ field = (change_item.get("field") or "").lower()
394
+
395
+ # Loop prevention: skip if this exact change was just sent Impact -> Jira
396
+ if self._skip_due_to_recent_impact_change(jira_ticket_key, field, change_item):
397
+ logger.debug(
398
+ "Skipping Jira→Impact sync for %s on %s due to recent Impact change",
399
+ field,
400
+ jira_ticket_key,
401
+ )
402
+ return False
403
+
404
+ # Detect and apply status/priority updates only
405
+ if field == "status":
406
+ if not self._alert_slack_update(
407
+ validated_data, jira_ticket_key, change_item
408
+ ):
306
409
  raise SlackNotificationError("Could not alert in Slack")
410
+ return self._handle_status_update(validated_data, incident, change_item)
411
+
412
+ to_val = change_item.get("toString")
413
+ from_val = change_item.get("fromString")
414
+ if (
415
+ self._parse_priority_value(to_val) is not None
416
+ or self._parse_priority_value(from_val) is not None
417
+ ):
418
+ if not self._alert_slack_update(
419
+ validated_data, jira_ticket_key, change_item
420
+ ):
421
+ raise SlackNotificationError("Could not alert in Slack")
422
+ return self._handle_priority_update(validated_data, incident, change_item)
423
+
424
+ return False
307
425
 
426
+ @staticmethod
427
+ def _get_incident_from_jira_ticket(jira_ticket_key: str) -> Incident | None:
428
+ try:
429
+ jira_ticket = JiraTicket.objects.select_related("incident").get(
430
+ key=jira_ticket_key
431
+ )
432
+ except JiraTicket.DoesNotExist:
433
+ logger.warning(
434
+ "Received Jira webhook for %s but no JiraTicket found; skipping",
435
+ jira_ticket_key,
436
+ )
437
+ return None
438
+
439
+ incident = getattr(jira_ticket, "incident", None)
440
+ if incident is None:
441
+ logger.warning(
442
+ "Jira ticket %s not linked to an incident; skipping sync",
443
+ jira_ticket_key,
444
+ )
445
+ return None
446
+
447
+ return incident
448
+
449
+ def _handle_status_update(
450
+ self,
451
+ _validated_data: dict[str, Any],
452
+ incident: Incident,
453
+ change_item: dict[str, Any],
454
+ ) -> bool:
455
+ jira_status_to = change_item.get("toString") or ""
456
+ impact_status = JIRA_TO_IMPACT_STATUS_MAP.get(jira_status_to)
457
+ if impact_status is None:
458
+ logger.debug(
459
+ "Jira status '%s' has no Impact mapping; skipping incident sync",
460
+ jira_status_to,
461
+ )
462
+ return True
463
+
464
+ if incident.status == impact_status:
465
+ logger.debug(
466
+ "Incident %s already at status %s from Jira webhook; no-op",
467
+ incident.id,
468
+ incident.status.label,
469
+ )
470
+ return True
471
+
472
+ if incident.needs_postmortem and impact_status == IncidentStatus.CLOSED:
473
+ if not hasattr(incident, "jira_postmortem_for"):
474
+ logger.warning(
475
+ "Skipping Jira→Impact close for incident %s: postmortem is required but no Jira PM linked.",
476
+ incident.id,
477
+ )
478
+ # Returning True: webhook handled but intentionally skipped due to missing Jira PM link.
479
+ return True
480
+ try:
481
+ is_ready, current_status = jira_postmortem_service.is_postmortem_ready(
482
+ incident.jira_postmortem_for
483
+ )
484
+ if not is_ready:
485
+ logger.warning(
486
+ "Skipping Jira→Impact close for incident %s: Jira PM %s not ready (status=%s).",
487
+ incident.id,
488
+ incident.jira_postmortem_for.jira_issue_key,
489
+ current_status,
490
+ )
491
+ # Returning True: webhook handled but close sync deferred until PM ready.
492
+ return True
493
+ except Exception:
494
+ logger.exception(
495
+ "Failed to verify Jira post-mortem readiness for incident %s; skipping close sync",
496
+ incident.id,
497
+ )
498
+ # Returning True: webhook handled; failure is logged, no retry desired here.
499
+ return True
500
+
501
+ incident.create_incident_update(
502
+ created_by=None,
503
+ status=impact_status,
504
+ message=f"Status synced from Jira ({jira_status_to})",
505
+ event_type="jira_status_sync",
506
+ )
308
507
  return True
309
508
 
509
+ def _handle_priority_update(
510
+ self,
511
+ _validated_data: dict[str, Any],
512
+ incident: Incident,
513
+ change_item: dict[str, Any],
514
+ ) -> bool:
515
+ jira_priority_to = change_item.get("toString") or ""
516
+
517
+ impact_priority_value = self._parse_priority_value(jira_priority_to)
518
+
519
+ if impact_priority_value is None:
520
+ logger.debug(
521
+ "Jira priority '%s' has no Impact mapping; skipping incident sync",
522
+ jira_priority_to,
523
+ )
524
+ return True
525
+ if incident.priority and incident.priority.value == impact_priority_value:
526
+ logger.debug(
527
+ "Incident %s already at priority %s from Jira webhook; no-op",
528
+ incident.id,
529
+ incident.priority.value,
530
+ )
531
+ return True
532
+
533
+ try:
534
+ impact_priority = Priority.objects.get(value=impact_priority_value)
535
+ except Priority.DoesNotExist:
536
+ logger.warning(
537
+ "No Impact priority with value %s; skipping Jira→Impact priority sync",
538
+ impact_priority_value,
539
+ )
540
+ return True
541
+
542
+ incident.create_incident_update(
543
+ created_by=None,
544
+ priority_id=impact_priority.id,
545
+ message=f"Priority synced from Jira ({jira_priority_to})",
546
+ event_type="jira_priority_sync",
547
+ )
548
+ return True
549
+
550
+ @staticmethod
551
+ def _skip_due_to_recent_impact_change(
552
+ jira_ticket_key: str | None, field: str, change_item: dict[str, Any]
553
+ ) -> bool:
554
+ if not jira_ticket_key:
555
+ return False
556
+ try:
557
+ jira_ticket = JiraTicket.objects.select_related("incident").get(
558
+ key=jira_ticket_key
559
+ )
560
+ except JiraTicket.DoesNotExist:
561
+ return False
562
+
563
+ incident = getattr(jira_ticket, "incident", None)
564
+ if not incident:
565
+ return False
566
+
567
+ raw_value = change_item.get("toString")
568
+ # Normalize value consistently with Impact→Jira writes
569
+ if field == "status":
570
+ value_for_cache = raw_value
571
+ else:
572
+ parsed = JiraWebhookUpdateSerializer._parse_priority_value(raw_value)
573
+ value_for_cache = parsed if parsed is not None else raw_value
574
+
575
+ cache_key = f"sync:impact_to_jira:{incident.id}:{field}:{normalize_cache_value(value_for_cache)}"
576
+ if cache.get(cache_key):
577
+ cache.delete(cache_key)
578
+ return True
579
+ return False
580
+
581
+ @staticmethod
582
+ def _parse_priority_value(raw: Any) -> int | None:
583
+ """Try to extract a priority value (1-5) from Jira changelog strings."""
584
+ if raw is None:
585
+ return None
586
+ try:
587
+ parsed = int(raw)
588
+ if 1 <= parsed <= 5:
589
+ return parsed
590
+ except (TypeError, ValueError):
591
+ pass
592
+
593
+ return JIRA_TO_IMPACT_PRIORITY_MAP.get(str(raw))
594
+
310
595
  def update(self, instance: Any, validated_data: Any) -> Any:
311
596
  raise NotImplementedError
312
597