firefighter-incident 0.0.36__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.
@@ -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