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.
- firefighter/_version.py +2 -2
- firefighter/incidents/models/incident.py +25 -13
- firefighter/incidents/models/incident_update.py +2 -8
- firefighter/incidents/static/css/main.min.css +1 -1
- firefighter/incidents/templates/pages/incident_detail.html +15 -2
- firefighter/incidents/views/views.py +28 -0
- firefighter/raid/client.py +5 -0
- firefighter/raid/serializers.py +311 -26
- firefighter/raid/signals/incident_updated.py +332 -17
- firefighter/raid/utils.py +13 -3
- {firefighter_incident-0.0.35.dist-info → firefighter_incident-0.0.37.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.35.dist-info → firefighter_incident-0.0.37.dist-info}/RECORD +20 -18
- firefighter_tests/test_incidents/test_views/test_incident_detail_jira_postmortem.py +63 -0
- firefighter_tests/test_raid/test_jira_status_sync.py +209 -0
- firefighter_tests/test_raid/test_raid_serializers.py +106 -46
- firefighter_tests/test_raid/test_raid_signals.py +43 -31
- firefighter_tests/test_slack/views/modals/test_update_status.py +28 -0
- {firefighter_incident-0.0.35.dist-info → firefighter_incident-0.0.37.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.35.dist-info → firefighter_incident-0.0.37.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.35.dist-info → firefighter_incident-0.0.37.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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}
|
firefighter/raid/client.py
CHANGED
|
@@ -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.
|
firefighter/raid/serializers.py
CHANGED
|
@@ -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,
|
|
142
|
-
|
|
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
|
-
|
|
283
|
-
if
|
|
284
|
-
"
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.get("
|
|
297
|
-
.get("
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|