T3MP3ST public launch build
This commit is contained in:
1 parent
972247b881
commit
184c7d4e59
1589 files changed
+57150
-506
No files matched your search
@@ -0,0 +1,6 @@
|
||||
"""RAID is ManoMano's internal issue dispatcher.
|
||||
|
||||
It works with Slack and Jira to create, update and close issues.
|
||||
|
||||
It is currently not configurable.
|
||||
"""
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import admin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from firefighter.jira_app.admin import JiraIssueAdmin
|
||||
from firefighter.raid.models import (
|
||||
FeatureTeam,
|
||||
JiraTicket,
|
||||
JiraTicketImpact,
|
||||
)
|
||||
from firefighter.raid.resources import FeatureTeamResource
|
||||
|
||||
|
||||
class JiraTicketImpactInline(admin.TabularInline[JiraTicketImpact, JiraTicket]):
|
||||
model = JiraTicketImpact
|
||||
extra = 0
|
||||
raw_id_fields = ["impact"]
|
||||
|
||||
|
||||
@admin.register(JiraTicket)
|
||||
class JiraTicketAdmin(JiraIssueAdmin):
|
||||
model = JiraTicket
|
||||
list_display = ["id", "key", "summary", "incident"]
|
||||
list_display_links = ["id", "key", "summary"]
|
||||
search_fields = ["id", "key", "summary", "description"]
|
||||
list_select_related = ["incident"]
|
||||
|
||||
autocomplete_fields = ["watchers", "assignee", "reporter", "incident"]
|
||||
inlines = [JiraTicketImpactInline]
|
||||
|
||||
|
||||
@admin.register(FeatureTeam)
|
||||
class FeatureTeamAdmin(ImportExportModelAdmin):
|
||||
resource_class = FeatureTeamResource
|
||||
model = FeatureTeam
|
||||
list_display = [
|
||||
"name",
|
||||
"jira_project_key",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RaidConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
label = "raid"
|
||||
name = "firefighter.raid"
|
||||
verbose_name = "RAID"
|
||||
|
||||
def ready(self) -> None:
|
||||
import firefighter.raid.tasks
|
||||
import firefighter.raid.urls
|
||||
from firefighter.raid.signals import (
|
||||
incident_updated,
|
||||
)
|
||||
from firefighter.slack.views.modals.open import INCIDENT_TYPES
|
||||
from firefighter.slack.views.modals.opening.details.unified import (
|
||||
OpeningUnifiedModal,
|
||||
)
|
||||
|
||||
# Use unified form for all normal incidents (P4-P5)
|
||||
# This replaces the previous 5 separate forms (Customer/Seller/Internal/Doc/Feature)
|
||||
# STEP 3 (incident type selection) will be automatically hidden since len() == 1
|
||||
INCIDENT_TYPES["normal"] = {
|
||||
"normal": {
|
||||
"label": "Normal",
|
||||
"slack_form": OpeningUnifiedModal,
|
||||
},
|
||||
}
|
||||
|
||||
return super().ready()
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from django.conf import settings
|
||||
from httpx import HTTPError
|
||||
from jira.exceptions import JIRAError
|
||||
|
||||
from firefighter.firefighter.http_client import HttpClient
|
||||
from firefighter.firefighter.utils import get_in
|
||||
from firefighter.jira_app.client import JiraClient
|
||||
from firefighter.raid.models import FeatureTeam
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from jira import Project
|
||||
|
||||
from firefighter.jira_app.types import WorkflowBuilderResponse
|
||||
from firefighter.raid.types import (
|
||||
JiraObject,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RAID_JIRA_PROJECT_KEY: Final[str] = settings.RAID_JIRA_PROJECT_KEY
|
||||
TOOLBOX_URL: Final[str] = settings.RAID_TOOLBOX_URL
|
||||
# XXX Do not hardcode this, it should be a setting or fetched from Jira
|
||||
RAID_JIRA_WORKFLOW_NAME: Final[str] = "Incident workflow - v2023.03.13"
|
||||
TARGET_STATUS_NAME: Final[str] = "Closed"
|
||||
|
||||
|
||||
class JiraAttachmentError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RaidJiraClient(JiraClient):
|
||||
def create_issue( # noqa: PLR0912, PLR0913, C901
|
||||
self,
|
||||
issuetype: str | None,
|
||||
summary: str,
|
||||
description: str,
|
||||
assignee: str | None,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None = None,
|
||||
zoho_desk_ticket_id: str | int | None = None,
|
||||
zendesk_ticket_id: str | int | None = None,
|
||||
is_seller_in_golden_list: bool | None = None,
|
||||
is_key_account: bool | None = None,
|
||||
seller_contract_id: int | str | None = None,
|
||||
suggested_team_routing: str | None = None,
|
||||
business_impact: str | None = None,
|
||||
platform: str | None = None,
|
||||
environments: list[str] | None = None,
|
||||
incident_category: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> JiraObject:
|
||||
description_addendum: list[str] = []
|
||||
extra_args: dict[str, Any] = {}
|
||||
if assignee:
|
||||
extra_args["assignee"] = {"id": assignee}
|
||||
if labels is None:
|
||||
labels = [""]
|
||||
if priority is None:
|
||||
priority_value = "-"
|
||||
else:
|
||||
if not 1 <= priority <= 5:
|
||||
raise ValueError("Priority must be between 1 and 5")
|
||||
priority_value = str(priority)
|
||||
if zoho_desk_ticket_id:
|
||||
extra_args["customfield_10896"] = str(zoho_desk_ticket_id)
|
||||
if zendesk_ticket_id:
|
||||
extra_args["customfield_10895"] = str(zendesk_ticket_id)
|
||||
|
||||
if seller_contract_id:
|
||||
description_addendum.append(
|
||||
f"Seller link to TOOLBOX: {TOOLBOX_URL}?seller_id={seller_contract_id}"
|
||||
)
|
||||
extra_args["customfield_10908"] = str(seller_contract_id)
|
||||
if is_seller_in_golden_list is True:
|
||||
labels = [*labels, "goldenList"]
|
||||
if is_key_account is True:
|
||||
labels = [*labels, "keyAccount"]
|
||||
if suggested_team_routing and suggested_team_routing != "SBI":
|
||||
description_addendum.append(
|
||||
f"Suggested team to be routed: *{suggested_team_routing}*"
|
||||
)
|
||||
if business_impact and business_impact != "N/A":
|
||||
if business_impact not in {"Lowest", "Low", "Medium", "High", "Highest"}:
|
||||
raise ValueError("Business impact must be Lowest, Low, Medium, High or Highest")
|
||||
extra_args["customfield_10936"] = {"value": business_impact}
|
||||
if platform:
|
||||
platform = platform.replace("platform-", "")
|
||||
extra_args["customfield_10201"] = {"value": platform}
|
||||
if environments:
|
||||
extra_args["customfield_11049"] = [{"value": e} for e in environments]
|
||||
if incident_category and settings.RAID_JIRA_INCIDENT_CATEGORY_FIELD:
|
||||
extra_args[settings.RAID_JIRA_INCIDENT_CATEGORY_FIELD] = {"value": incident_category}
|
||||
if len(description_addendum) > 0:
|
||||
description_addendum_str = "\n *Additional Information* \n" + "\n".join(
|
||||
description_addendum
|
||||
)
|
||||
description += description_addendum_str
|
||||
if project is None:
|
||||
try:
|
||||
feature_team = FeatureTeam.objects.get(name=suggested_team_routing)
|
||||
except FeatureTeam.DoesNotExist:
|
||||
feature_team = None
|
||||
project = (
|
||||
feature_team.jira_project_key if feature_team else RAID_JIRA_PROJECT_KEY
|
||||
)
|
||||
|
||||
issue = self.jira.create_issue(
|
||||
project=project,
|
||||
summary=summary,
|
||||
description=description,
|
||||
issuetype={"name": issuetype},
|
||||
reporter={"id": reporter},
|
||||
customfield_11064={"value": priority_value},
|
||||
labels=labels,
|
||||
**extra_args,
|
||||
)
|
||||
return self._jira_object(issue.raw)
|
||||
|
||||
def get_projects(self) -> list[Project]:
|
||||
return self.jira.projects()
|
||||
|
||||
def update_issue_fields(self, issue_id: str | int, **fields: Any) -> None:
|
||||
"""Update Jira issue fields (supports custom fields like customfield_11064)."""
|
||||
issue = self.jira.issue(str(issue_id))
|
||||
issue.update(fields=fields)
|
||||
|
||||
@staticmethod
|
||||
def add_attachments_to_issue(issue_id: str | int, urls: list[str]) -> None:
|
||||
"""Add attachments to a Jira issue.
|
||||
|
||||
Args:
|
||||
issue_id (str | int): the Jira issue id
|
||||
urls (list[str]): list of urls to the attachments
|
||||
|
||||
Raises:
|
||||
JiraAttachmentError: if there is an error while adding any attachment
|
||||
"""
|
||||
http_client = HttpClient()
|
||||
for i, url in enumerate(urls):
|
||||
index = url.rfind(".")
|
||||
extension = url[index:]
|
||||
try:
|
||||
response = http_client.get(url)
|
||||
response.raise_for_status() # Raises an exception if status code is not 2XX
|
||||
|
||||
in_memory_file = io.BytesIO(response.content)
|
||||
|
||||
client.jira.add_attachment(
|
||||
issue=issue_id,
|
||||
attachment=in_memory_file,
|
||||
filename=f"image{i}{extension}",
|
||||
)
|
||||
|
||||
except (HTTPError, JIRAError) as err:
|
||||
msg = f"Error while adding attachment to issue: {err}"
|
||||
raise JiraAttachmentError(msg) from err
|
||||
|
||||
def close_issue(
|
||||
self,
|
||||
issue_id: str | int,
|
||||
) -> None:
|
||||
return self.transition_issue_auto(
|
||||
issue_id, TARGET_STATUS_NAME, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
|
||||
def _get_project_config_workflow(
|
||||
self, project_key: str = RAID_JIRA_PROJECT_KEY
|
||||
) -> dict[str, Any]:
|
||||
return self._get_project_config_workflow_base(
|
||||
project_key, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
|
||||
def _get_project_config_workflow_from_builder(self) -> WorkflowBuilderResponse:
|
||||
return self._get_project_config_workflow_from_builder_base(
|
||||
RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _jira_object(issue: dict[str, Any]) -> JiraObject:
|
||||
if issue_id := issue.get("id"):
|
||||
jira_id = int(cast("str", issue_id))
|
||||
else:
|
||||
raise TypeError("Jira ID not found")
|
||||
|
||||
jira_key = issue.get("key")
|
||||
jira_assignee_id = get_in(issue, ["fields", "assignee", "accountId"])
|
||||
jira_reporter_id = get_in(issue, ["fields", "reporter", "accountId"])
|
||||
jira_description = get_in(issue, ["fields", "description"])
|
||||
jira_summary = get_in(issue, ["fields", "summary"])
|
||||
jira_issue_type = get_in(issue, ["fields", "issuetype", "name"])
|
||||
# If any field is not string
|
||||
if None in {
|
||||
jira_reporter_id,
|
||||
jira_description,
|
||||
jira_summary,
|
||||
jira_issue_type,
|
||||
}:
|
||||
raise TypeError("Jira object has wrong type")
|
||||
|
||||
if jira_key is None:
|
||||
raise TypeError("Jira key is None")
|
||||
|
||||
return {
|
||||
"id": jira_id,
|
||||
"key": jira_key,
|
||||
"project_key": str(jira_key.split("-")[0]),
|
||||
"assignee_id": jira_assignee_id,
|
||||
"reporter_id": jira_reporter_id,
|
||||
"description": jira_description,
|
||||
"summary": jira_summary,
|
||||
"issue_type": jira_issue_type,
|
||||
"business_impact": get_in(
|
||||
issue, ["fields", "customfield_10936", "value"], default=""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
client = RaidJiraClient()
|
||||
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Never
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
from firefighter.incidents.forms.select_impact import SelectImpactForm
|
||||
from firefighter.incidents.models.priority import Priority
|
||||
from firefighter.jira_app.client import (
|
||||
JiraAPIError,
|
||||
JiraUserNotFoundError,
|
||||
)
|
||||
from firefighter.jira_app.models import JiraUser
|
||||
from firefighter.raid.client import client as jira_client
|
||||
from firefighter.raid.messages import (
|
||||
SlackMessageRaidComment,
|
||||
SlackMessageRaidCreatedIssue,
|
||||
SlackMessageRaidModifiedIssue,
|
||||
)
|
||||
from firefighter.raid.models import JiraTicket
|
||||
from firefighter.raid.utils import get_domain_from_email
|
||||
from firefighter.slack.models.conversation import Conversation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from firefighter.incidents.models.impact import ImpactLevel
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.raid.types import JiraObject
|
||||
from firefighter.slack.messages.base import SlackMessageSurface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID: str = settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
|
||||
|
||||
class PlatformChoices(models.TextChoices):
|
||||
FR = "platform-FR", ":fr: FR"
|
||||
DE = "platform-DE", ":de: DE"
|
||||
IT = "platform-IT", ":it: IT"
|
||||
ES = "platform-ES", ":es: ES"
|
||||
UK = "platform-UK", ":uk: UK"
|
||||
ALL = "platform-All", ":earth_africa: ALL"
|
||||
INTERNAL = "platform-Internal", ":logo-manomano: Internal"
|
||||
|
||||
|
||||
def initial_priority() -> Priority:
|
||||
return Priority.objects.get(default=True)
|
||||
|
||||
|
||||
# NOTE: Incident creation forms have been unified and moved to:
|
||||
# firefighter.incidents.forms.unified_incident.UnifiedIncidentForm
|
||||
# This handles all incident types (P1-P5) with dynamic field visibility.
|
||||
# The following utility functions remain here as they are used by the unified form
|
||||
# and by JIRA webhook handlers.
|
||||
|
||||
|
||||
def process_jira_issue(
|
||||
issue_data: JiraObject,
|
||||
user: User,
|
||||
jira_user: JiraUser,
|
||||
impacts_data: dict[str, ImpactLevel],
|
||||
*args: Never,
|
||||
**kwargs: Never,
|
||||
) -> None:
|
||||
# XXX Deduplicate from these forms and from incident_created signal
|
||||
jira_ticket = JiraTicket.objects.create(**issue_data)
|
||||
impacts_form = SelectImpactForm(impacts_data)
|
||||
|
||||
impacts_form.save(incident=jira_ticket)
|
||||
|
||||
set_jira_ticket_watchers_raid(jira_ticket)
|
||||
alert_slack_new_jira_ticket(jira_ticket)
|
||||
|
||||
|
||||
def set_jira_ticket_watchers_raid(jira_ticket: JiraTicket) -> None:
|
||||
issue_id = jira_ticket.id
|
||||
reporter = jira_ticket.reporter.id
|
||||
|
||||
try:
|
||||
default_jira_user = jira_client.get_jira_user_from_jira_id(
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
)
|
||||
except JiraUserNotFoundError:
|
||||
logger.exception(
|
||||
f"Could not find Jira user with account id {RAID_DEFAULT_JIRA_QRAFT_USER_ID}"
|
||||
)
|
||||
try:
|
||||
jira_client.jira.add_watcher(issue=issue_id, watcher=reporter)
|
||||
except JiraAPIError:
|
||||
logger.exception(
|
||||
f"Could not add the watcher {jira_ticket.reporter.id} to the ticket {issue_id}"
|
||||
)
|
||||
try:
|
||||
jira_client.jira.remove_watcher(issue=issue_id, watcher=default_jira_user)
|
||||
|
||||
except JiraAPIError:
|
||||
logger.exception(
|
||||
f"Could not add the watcher {jira_ticket.reporter.id} to the ticket {issue_id}"
|
||||
)
|
||||
|
||||
|
||||
def alert_slack_new_jira_ticket(
|
||||
jira_ticket: JiraTicket,
|
||||
reporter_user: User | None = None,
|
||||
reporter_email: str | None = None,
|
||||
) -> None:
|
||||
# These alerts are for P4-P5 incidents only, not P1-P3 critical incidents
|
||||
if (
|
||||
hasattr(jira_ticket, "incident")
|
||||
and jira_ticket.incident
|
||||
and jira_ticket.incident.priority.value <= 3
|
||||
):
|
||||
raise ValueError("This is a critical incident, not a raid incident.")
|
||||
|
||||
# Get the reporter's email and user from ticket if not provided
|
||||
reporter_user = reporter_user or jira_ticket.reporter.user
|
||||
reporter_email = reporter_email or reporter_user.email
|
||||
# Get the user's email domain (without @, without subdomain(s), with tld)
|
||||
user_domain: str = get_domain_from_email(reporter_email)
|
||||
|
||||
if not reporter_user:
|
||||
logger.warning(f"Reporter user not found for Jira ticket {jira_ticket.id}.")
|
||||
return
|
||||
message = SlackMessageRaidCreatedIssue(jira_ticket, reporter_user=reporter_user)
|
||||
if (
|
||||
hasattr(reporter_user, "slack_user")
|
||||
and reporter_user.slack_user
|
||||
and reporter_user.slack_user.slack_id
|
||||
):
|
||||
try:
|
||||
# XXX: Slack ID is posted in the message instead of Name
|
||||
reporter_user.slack_user.send_private_message(
|
||||
message,
|
||||
unfurl_links=False,
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get("error") == "messages_tab_disabled":
|
||||
logger.warning(
|
||||
f"User {reporter_user.slack_user} has disabled private messages from bots"
|
||||
)
|
||||
else:
|
||||
logger.exception(
|
||||
f"Couldn't send private message to reporter {reporter_user.slack_user}"
|
||||
)
|
||||
|
||||
# Get the right channels from tags
|
||||
channels = get_internal_alert_conversations(jira_ticket)
|
||||
if user_domain:
|
||||
channels.union(get_partner_alert_conversations(user_domain))
|
||||
channels_list = list(channels)
|
||||
if len(channels_list) == 0:
|
||||
logger.warning(
|
||||
f"No channel to send notification for Jira ticket {jira_ticket.id}."
|
||||
)
|
||||
|
||||
for channel in channels_list:
|
||||
try:
|
||||
channel.send_message_and_save(message)
|
||||
except SlackApiError:
|
||||
logger.exception(
|
||||
f"Couldn't send message to channel {channel} for ticket {jira_ticket.id}"
|
||||
)
|
||||
|
||||
|
||||
def alert_slack_update_ticket(
|
||||
jira_ticket_id: int,
|
||||
jira_ticket_key: str,
|
||||
jira_author_name: str,
|
||||
jira_field_modified: str,
|
||||
jira_field_from: str,
|
||||
jira_field_to: str,
|
||||
) -> bool:
|
||||
message = SlackMessageRaidModifiedIssue(
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
jira_author_name=jira_author_name,
|
||||
jira_field_modified=jira_field_modified,
|
||||
jira_field_from=jira_field_from,
|
||||
jira_field_to=jira_field_to,
|
||||
)
|
||||
return send_message_to_watchers(jira_issue_id=jira_ticket_id, message=message)
|
||||
|
||||
|
||||
def alert_slack_comment_ticket(
|
||||
webhook_event: str,
|
||||
jira_ticket_id: int,
|
||||
jira_ticket_key: str,
|
||||
author_jira_name: str,
|
||||
comment: str,
|
||||
) -> bool:
|
||||
message: SlackMessageSurface = SlackMessageRaidComment(
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
author_jira_name=author_jira_name,
|
||||
comment=comment,
|
||||
webhook_event=webhook_event,
|
||||
)
|
||||
return send_message_to_watchers(jira_issue_id=jira_ticket_id, message=message)
|
||||
|
||||
|
||||
def send_message_to_watchers(
|
||||
jira_issue_id: int,
|
||||
message: SlackMessageSurface,
|
||||
) -> bool:
|
||||
watchers = jira_client.get_watchers_from_jira_ticket(jira_issue_id)
|
||||
if not watchers:
|
||||
return True
|
||||
|
||||
for watcher in watchers:
|
||||
try:
|
||||
watcher_account_id = watcher.get("accountId")
|
||||
if watcher_account_id is None:
|
||||
logger.warning(f"Couldn't find Jira account ID for watcher {watcher}")
|
||||
continue
|
||||
|
||||
if watcher.get("accountType") == "app":
|
||||
logger.info(
|
||||
f"Skipping sending message to jira_user_id={watcher_account_id}: is an app"
|
||||
)
|
||||
continue
|
||||
if not JiraUser.objects.filter(id=watcher_account_id).exists():
|
||||
logger.debug(
|
||||
"Skipping watcher %s: no JiraUser mapping in database",
|
||||
watcher_account_id,
|
||||
)
|
||||
continue
|
||||
watcher_jira_user = jira_client.get_jira_user_from_jira_id(
|
||||
watcher_account_id
|
||||
)
|
||||
watcher_slack_user = (
|
||||
watcher_jira_user.user.slack_user
|
||||
if hasattr(watcher_jira_user.user, "slack_user")
|
||||
else None
|
||||
)
|
||||
if watcher_slack_user is None:
|
||||
logger.debug(
|
||||
"Watcher %s has a JiraUser but no linked Slack user",
|
||||
watcher_account_id,
|
||||
)
|
||||
continue
|
||||
watcher_slack_user.send_private_message(
|
||||
message,
|
||||
unfurl_links=False,
|
||||
)
|
||||
except SlackApiError:
|
||||
logger.warning(
|
||||
f"Couldn't send private message to reporter with jira_id={watcher.get('accountId', watcher)}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_business_impact(impacts_data: dict[str, ImpactLevel]) -> str | None:
|
||||
impact_form = SelectImpactForm(impacts_data)
|
||||
return impact_form.business_impact_new
|
||||
|
||||
|
||||
def prepare_jira_fields(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
priority: int,
|
||||
reporter: str,
|
||||
incident_category: str,
|
||||
environments: list[str],
|
||||
platforms: list[str],
|
||||
impacts_data: dict[str, ImpactLevel],
|
||||
optional_fields: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Prepare all fields for jira_client.create_issue().
|
||||
|
||||
This function centralizes Jira field preparation for both P1-P3 and P4-P5 incidents,
|
||||
ensuring all custom fields are properly passed.
|
||||
|
||||
Args:
|
||||
title: Incident title
|
||||
description: Incident description
|
||||
priority: Priority value (1-5)
|
||||
reporter: Jira user account ID
|
||||
incident_category: Category name
|
||||
environments: List of environment values (e.g. ["PRD", "STG"])
|
||||
platforms: List of platform values (e.g. ["platform-FR", "platform-DE"])
|
||||
impacts_data: Dictionary of impact data for business_impact computation
|
||||
optional_fields: Optional dictionary containing:
|
||||
- zendesk_ticket_id: Zendesk ticket ID (customer-specific)
|
||||
- seller_contract_id: Seller contract ID (seller-specific)
|
||||
- zoho_desk_ticket_id: Zoho Desk ticket ID (seller-specific)
|
||||
- is_key_account: Key account flag (seller-specific)
|
||||
- is_seller_in_golden_list: Golden list flag (seller-specific)
|
||||
- suggested_team_routing: Suggested team routing (P4-P5 only)
|
||||
|
||||
Returns:
|
||||
Dictionary of kwargs ready for jira_client.create_issue()
|
||||
"""
|
||||
business_impact = get_business_impact(impacts_data)
|
||||
platform = platforms[0] if platforms else PlatformChoices.ALL.value
|
||||
|
||||
# Extract optional fields with defaults
|
||||
opt = optional_fields or {}
|
||||
zendesk_ticket_id = opt.get("zendesk_ticket_id", "")
|
||||
seller_contract_id = opt.get("seller_contract_id", "")
|
||||
zoho_desk_ticket_id = opt.get("zoho_desk_ticket_id", "")
|
||||
is_key_account = opt.get("is_key_account")
|
||||
is_seller_in_golden_list = opt.get("is_seller_in_golden_list")
|
||||
suggested_team_routing = opt.get("suggested_team_routing")
|
||||
|
||||
return {
|
||||
"issuetype": "Incident",
|
||||
"summary": title,
|
||||
"description": description,
|
||||
"priority": priority,
|
||||
"reporter": reporter,
|
||||
"assignee": None,
|
||||
"incident_category": incident_category,
|
||||
"environments": environments, # ✅ Always pass environments list
|
||||
"platform": platform,
|
||||
"business_impact": business_impact,
|
||||
"zendesk_ticket_id": zendesk_ticket_id,
|
||||
"seller_contract_id": seller_contract_id,
|
||||
"zoho_desk_ticket_id": zoho_desk_ticket_id,
|
||||
"is_key_account": is_key_account if is_key_account is not None else False,
|
||||
"is_seller_in_golden_list": is_seller_in_golden_list if is_seller_in_golden_list is not None else False,
|
||||
"suggested_team_routing": suggested_team_routing,
|
||||
}
|
||||
|
||||
|
||||
def get_partner_alert_conversations(user_domain: str) -> QuerySet[Conversation]:
|
||||
# Get the right channel from tags
|
||||
return Conversation.objects.filter(tag__contains=f"raid_alert__{user_domain}")
|
||||
|
||||
|
||||
def get_internal_alert_conversations(jira_ticket: JiraTicket) -> QuerySet[Conversation]:
|
||||
impact_tag = "high" if jira_ticket.business_impact == "High" else "normal"
|
||||
project = "sbi" if jira_ticket.project_key == "SBI" else "incidents"
|
||||
return Conversation.objects.filter(
|
||||
tag__contains=f"raid_alert__{project}_{impact_tag}"
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from slack_sdk.models.blocks.basic_components import MarkdownTextObject
|
||||
from slack_sdk.models.blocks.blocks import (
|
||||
Block,
|
||||
DividerBlock,
|
||||
SectionBlock,
|
||||
)
|
||||
|
||||
from firefighter.slack.messages.base import SlackMessageSurface
|
||||
from firefighter.slack.slack_templating import user_slack_handle_or_name
|
||||
|
||||
RAID_JIRA_API_URL: str = settings.RAID_JIRA_API_URL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.raid.models import JiraTicket
|
||||
|
||||
|
||||
class SlackMessageRaidCreatedIssue(SlackMessageSurface):
|
||||
id = "raid_created_issue"
|
||||
|
||||
def __init__(self, ticket: JiraTicket, reporter_user: User | None = None) -> None:
|
||||
self.ticket = ticket
|
||||
self.reporter_user = reporter_user or ticket.reporter.user
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return f"New {self.ticket.issue_type}: {self.ticket.key} '{self.ticket.summary}' was created."
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
blocks: list[Block] = [
|
||||
SectionBlock(
|
||||
text=f"New {self.ticket.issue_type}: *{self.ticket.key}* _ {self.ticket.summary} _ was created."
|
||||
),
|
||||
# XXX Add issue type emoji from RAID
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
fields=[
|
||||
MarkdownTextObject(text=f":tag: *Title:*\n{self.ticket.summary}"),
|
||||
MarkdownTextObject(
|
||||
text=textwrap.shorten(
|
||||
f":spiral_note_pad: *Description:*\n{self.ticket.description}",
|
||||
width=1997,
|
||||
placeholder="...",
|
||||
)
|
||||
),
|
||||
MarkdownTextObject(
|
||||
text=f":happydoge: *Reporter:*\n{user_slack_handle_or_name(self.reporter_user)}"
|
||||
),
|
||||
MarkdownTextObject(text=f":jira_new: *Jira:*\n{self.ticket.url}"),
|
||||
MarkdownTextObject(
|
||||
text=f":warning: *Business impact*: {self.ticket.business_impact or 'N/A'}"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
return blocks
|
||||
|
||||
|
||||
class SlackMessageRaidModifiedIssue(SlackMessageSurface):
|
||||
id = "raid_modify_issue"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jira_ticket_key: str,
|
||||
jira_author_name: str,
|
||||
jira_field_modified: str,
|
||||
jira_field_from: str,
|
||||
jira_field_to: str,
|
||||
) -> None:
|
||||
self.jira_ticket_key = jira_ticket_key
|
||||
self.jira_author_name = jira_author_name
|
||||
self.jira_field_modified = jira_field_modified
|
||||
self.jira_field_from = jira_field_from
|
||||
self.jira_field_to = jira_field_to
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return f"Modified Jira ticket {self.jira_ticket_key}"
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
return [
|
||||
SectionBlock(
|
||||
text=f":jira_new: Jira ticket *{self.jira_ticket_key}* was *modified* :hammer:"
|
||||
),
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
fields=[
|
||||
MarkdownTextObject(
|
||||
text=f":face_with_monocle: *Author:*\n{self.jira_author_name}"
|
||||
),
|
||||
MarkdownTextObject(
|
||||
text=f":tag: *Field:*\n{self.jira_field_modified}"
|
||||
),
|
||||
MarkdownTextObject(text=f":x: *From:*\n{self.jira_field_from}"),
|
||||
MarkdownTextObject(
|
||||
text=f":light_check_mark: *To:*\n{self.jira_field_to}"
|
||||
),
|
||||
MarkdownTextObject(
|
||||
text=f":jira_new: *URL:*\n{RAID_JIRA_API_URL}/browse/{self.jira_ticket_key}"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SlackMessageRaidComment(SlackMessageSurface):
|
||||
id = "raid_comment"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jira_ticket_key: str,
|
||||
author_jira_name: str,
|
||||
comment: str,
|
||||
webhook_event: str,
|
||||
) -> None:
|
||||
self.jira_ticket_key = jira_ticket_key
|
||||
self.author_jira_name = author_jira_name
|
||||
self.comment = comment
|
||||
self.webhook_event = webhook_event
|
||||
match self.webhook_event:
|
||||
case "comment_created":
|
||||
self.comment_event_text = ":green_book: New comment added"
|
||||
case "comment_updated":
|
||||
self.comment_event_text = ":blue_book: Updated comment"
|
||||
case "comment_deleted":
|
||||
self.comment_event_text = ":closed_book: Deleted comment"
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return f"{self.comment_event_text} on {self.jira_ticket_key}"
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
return [
|
||||
SectionBlock(
|
||||
text=f"{self.comment_event_text} on the Jira ticket *{self.jira_ticket_key}*"
|
||||
),
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
fields=[
|
||||
MarkdownTextObject(
|
||||
text=f":face_with_monocle: *Author:*\n{self.author_jira_name}"
|
||||
),
|
||||
MarkdownTextObject(text=f":pencil2: *Comment:*\n{self.comment}"),
|
||||
MarkdownTextObject(
|
||||
text=f":jira_new: *Jira:*\n{RAID_JIRA_API_URL}/browse/{self.jira_ticket_key}"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SlackMessageJiraClosedBlockedByCanBeClosed(SlackMessageSurface):
|
||||
"""Sent to the incident channel when a Jira 'Closed' webhook was received but the incident cannot be automatically closed (e.g. missing key events)."""
|
||||
|
||||
id = "raid_jira_closed_blocked"
|
||||
|
||||
def __init__(self, jira_ticket_key: str, reasons: list[tuple[str, str]]) -> None:
|
||||
self.jira_ticket_key = jira_ticket_key
|
||||
self.reasons = reasons
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return (
|
||||
f"Jira ticket {self.jira_ticket_key} was closed, "
|
||||
"but the incident could not be automatically closed."
|
||||
)
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
reasons_text = "\n".join(f"• {reason}" for _, reason in self.reasons)
|
||||
return [
|
||||
SectionBlock(
|
||||
text=(
|
||||
f":jira_new: Jira ticket *{self.jira_ticket_key}* was closed, "
|
||||
"but the incident was *not* automatically closed."
|
||||
)
|
||||
),
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
text=f":warning: *The following must be resolved before closing:*\n{reasons_text}"
|
||||
),
|
||||
SectionBlock(
|
||||
text="Once resolved, close the incident using the _Close incident_ action in this channel."
|
||||
),
|
||||
]
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# Generated by Django 4.2.7 on 2023-12-04 10:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [
|
||||
("raid", "0001_initial_squashed_0008_qualifierrotation"),
|
||||
("raid", "0009_alter_jiraticket_business_impact"),
|
||||
("raid", "0010_raidarea"),
|
||||
("raid", "0010_jiraticket_issue_type_jiraticket_project_key"),
|
||||
("raid", "0011_merge_20230408_1956"),
|
||||
("raid", "0012_jiraticketimpact_jiraticket_impacts"),
|
||||
("raid", "0013_alter_jiraticket_watchers"),
|
||||
("raid", "0014_alter_jiraticket_business_impact"),
|
||||
("raid", "0015_alter_jiraticket_assignee_alter_jiraticket_reporter_and_more"),
|
||||
("raid", "0016_remove_jiraticket_assignee_and_more"),
|
||||
("raid", "0017_alter_qualifierrotation_options_and_more"),
|
||||
("raid", "0018_alter_qualifier_jira_user_and_more"),
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
("jira_app", "0005_alter_jiraissue_assignee"),
|
||||
("incidents", "0036_severity_enabled_create_severity_enabled_update_and_more"),
|
||||
(
|
||||
"incidents",
|
||||
"0039_impact_impacttype_incidentimpact_impact_impact_type_and_more",
|
||||
),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("jira_app", "0001_initial"),
|
||||
("jira_app", "0003_jiraissue"),
|
||||
]
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="JiraTicket",
|
||||
fields=[
|
||||
(
|
||||
"jiraissue_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="jira_app.jiraissue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"business_impact",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="It maps with customfield_10936",
|
||||
max_length=128,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Jira ticket",
|
||||
"verbose_name_plural": "Jira tickets",
|
||||
},
|
||||
bases=("jira_app.jiraissue",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RaidArea",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=128)),
|
||||
(
|
||||
"area",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Sellers", "Sellers"),
|
||||
("Internal", "Internal"),
|
||||
("Customers", "Customers"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("name", "area")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="QualifierRotation",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("day", models.DateField(unique=True)),
|
||||
(
|
||||
"protected",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If checked, it is because this rotation was previously modified so this date won't be deleted and it can not be unprotected again from here.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"jira_user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="jira_app.jirauser",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Qualifiers rotation",
|
||||
"verbose_name_plural": "Qualifiers rotation",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Qualifier",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"jira_user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="jira_app.jirauser",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Qualifier",
|
||||
"verbose_name_plural": "Qualifiers",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="JiraTicketImpact",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"impact",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="incidents.impact",
|
||||
),
|
||||
),
|
||||
(
|
||||
"jira_ticket",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="raid.jiraticket",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="jiraticket",
|
||||
name="impacts",
|
||||
field=models.ManyToManyField(
|
||||
through="raid.JiraTicketImpact", to="incidents.impact"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="jiraticket",
|
||||
name="incident",
|
||||
field=models.OneToOneField(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="jira_ticket",
|
||||
to="incidents.incident",
|
||||
),
|
||||
),
|
||||
]
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.11 on 2024-05-03 09:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("raid", "0001_initial_oss"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FeatureTeam",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=80)),
|
||||
("jira_project_key", models.CharField(max_length=10, unique=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Feature Team",
|
||||
"verbose_name_plural": "Feature Teams",
|
||||
"unique_together": {("name", "jira_project_key")},
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="qualifierrotation",
|
||||
name="jira_user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="Qualifier",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="QualifierRotation",
|
||||
),
|
||||
]
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 4.2.23 on 2025-09-17 17:49
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("raid", "0002_featureteam_remove_qualifierrotation_jira_user_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="RaidArea",
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django_stubs_ext.db.models import TypedModelMeta
|
||||
|
||||
from firefighter.incidents.models.incident import Incident
|
||||
from firefighter.jira_app.models import JiraIssue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.impact import Impact # noqa: F401
|
||||
|
||||
|
||||
class JiraTicket(JiraIssue):
|
||||
"""Jira ticket model."""
|
||||
|
||||
# XXX Incident/Impact should be split into new models
|
||||
incident = models.OneToOneField(
|
||||
Incident, on_delete=models.CASCADE, related_name="jira_ticket", null=True
|
||||
)
|
||||
impacts = models.ManyToManyField["Impact", "JiraTicketImpact"](
|
||||
"incidents.Impact", through="JiraTicketImpact"
|
||||
)
|
||||
|
||||
# TODO: priority
|
||||
# Jira Custom fields
|
||||
business_impact = models.CharField(
|
||||
max_length=128,
|
||||
help_text="It maps with customfield_10936",
|
||||
blank=True,
|
||||
null=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
class Meta(TypedModelMeta):
|
||||
verbose_name = "Jira ticket"
|
||||
verbose_name_plural = "Jira tickets"
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return self.url
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"{settings.RAID_JIRA_API_URL}/browse/{self.key}"
|
||||
|
||||
|
||||
class JiraTicketImpact(models.Model):
|
||||
jira_ticket = models.ForeignKey(
|
||||
"JiraTicket", on_delete=models.CASCADE
|
||||
)
|
||||
impact = models.ForeignKey(
|
||||
"incidents.Impact", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.jira_ticket.key}: {self.impact}"
|
||||
|
||||
|
||||
class FeatureTeam(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField(max_length=80)
|
||||
jira_project_key = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
class Meta(TypedModelMeta):
|
||||
unique_together = ("name", "jira_project_key")
|
||||
verbose_name = "Feature Team"
|
||||
verbose_name_plural = "Feature Teams"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def get_team(self) -> str:
|
||||
return f"{self.name} {self.jira_project_key}"
|
||||
|
||||
@property
|
||||
def get_key(self) -> str:
|
||||
return f"{self.jira_project_key}"
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from import_export import resources
|
||||
|
||||
from firefighter.raid.models import FeatureTeam
|
||||
|
||||
|
||||
class FeatureTeamResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = FeatureTeam
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
fields = ("id", "name", "jira_project_key")
|
||||
import_order = ("name", "jira_project_key")
|
||||
export_order = ("name", "jira_project_key")
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from rest_framework import serializers
|
||||
|
||||
from firefighter.incidents.enums import IncidentStatus
|
||||
from firefighter.incidents.models.priority import Priority
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.jira_app.client import (
|
||||
JiraAPIError,
|
||||
JiraUserNotFoundError,
|
||||
SlackNotificationError,
|
||||
)
|
||||
from firefighter.raid.client import client as jira_client
|
||||
from firefighter.raid.forms import (
|
||||
alert_slack_comment_ticket,
|
||||
alert_slack_new_jira_ticket,
|
||||
alert_slack_update_ticket,
|
||||
)
|
||||
from firefighter.raid.messages import SlackMessageJiraClosedBlockedByCanBeClosed
|
||||
from firefighter.raid.models import JiraTicket
|
||||
from firefighter.raid.utils import get_domain_from_email, normalize_cache_value
|
||||
from firefighter.slack.models.user import SlackUser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.incident import Incident
|
||||
from firefighter.jira_app.models import JiraUser
|
||||
|
||||
|
||||
JIRA_USER_IDS: dict[str, str] = settings.RAID_JIRA_USER_IDS
|
||||
JIRA_TO_IMPACT_STATUS_MAP: dict[str, IncidentStatus] = {
|
||||
"Incoming": IncidentStatus.OPEN,
|
||||
"Pending resolution": IncidentStatus.OPEN,
|
||||
"in progress": IncidentStatus.MITIGATING,
|
||||
"Reporter validation": IncidentStatus.MITIGATED,
|
||||
"Closed": IncidentStatus.CLOSED,
|
||||
}
|
||||
JIRA_TO_IMPACT_PRIORITY_MAP: dict[str, int] = {
|
||||
"1": 1,
|
||||
"2": 2,
|
||||
"3": 3,
|
||||
"4": 4,
|
||||
"5": 5,
|
||||
# Legacy Jira names still supported
|
||||
"Highest": 1,
|
||||
"High": 2,
|
||||
"Medium": 3,
|
||||
"Low": 4,
|
||||
"Lowest": 5,
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IgnoreEmptyStringListField(serializers.ListField):
|
||||
def to_internal_value(self, data: list[Any] | Any) -> list[str]:
|
||||
# Check if data is a list
|
||||
if not isinstance(data, list):
|
||||
msg = f'Expected a list but got type "{type(data).__name__}".'
|
||||
raise serializers.ValidationError(msg)
|
||||
|
||||
# Filter out any empty string values
|
||||
data = [item for item in data if item != ""]
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
def validate_no_spaces(value: str) -> None:
|
||||
"""Ensure the string does not contain spaces."""
|
||||
if " " in value:
|
||||
raise serializers.ValidationError("The string cannot contain spaces.")
|
||||
|
||||
|
||||
def get_reporter_user_from_email(reporter_email: str) -> tuple[User, JiraUser, str]:
|
||||
user_domain: str = get_domain_from_email(reporter_email)
|
||||
try:
|
||||
reporter_user: User = User.objects.get(email=reporter_email)
|
||||
reporter = jira_client.get_jira_user_from_user(reporter_user)
|
||||
except (User.DoesNotExist, JiraUserNotFoundError):
|
||||
reporter_user_tmp = SlackUser.objects.upsert_by_email(email=reporter_email)
|
||||
if reporter_user_tmp is not None:
|
||||
reporter_user = reporter_user_tmp
|
||||
|
||||
logger.warning(
|
||||
f"Reporter email {reporter_email} not found in Jira. Using default user."
|
||||
)
|
||||
|
||||
# Get default Jira user, depending on the domain
|
||||
# We might have accounts with Slack users, but not Jira users
|
||||
# In that case, we use the default Jira user for that domain
|
||||
match JIRA_USER_IDS.get(user_domain):
|
||||
case jira_user_id if jira_user_id is not None:
|
||||
reporter = jira_client.get_jira_user_from_jira_id(jira_user_id)
|
||||
reporter_user = (
|
||||
reporter.user if reporter_user_tmp is None else reporter_user_tmp
|
||||
)
|
||||
case _:
|
||||
reporter = jira_client.get_jira_user_from_jira_id(
|
||||
settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
)
|
||||
reporter_user = (
|
||||
reporter.user if reporter_user_tmp is None else reporter_user_tmp
|
||||
)
|
||||
return reporter_user, reporter, user_domain
|
||||
|
||||
|
||||
class LandbotIssueRequestSerializer(serializers.ModelSerializer[JiraTicket]):
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
key = serializers.CharField(read_only=True)
|
||||
seller_contract_id = serializers.CharField(
|
||||
max_length=128,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
zoho = serializers.CharField(
|
||||
max_length=256,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
zendesk = serializers.CharField(
|
||||
max_length=256,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
)
|
||||
platform = serializers.ChoiceField(
|
||||
write_only=True, choices=["ES", "IT", "FR", "UK", "DE", "All", "Internal"]
|
||||
)
|
||||
reporter_email = serializers.EmailField(write_only=True)
|
||||
|
||||
labels = serializers.ListField(
|
||||
required=False,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
default=list,
|
||||
child=serializers.CharField(
|
||||
max_length=128,
|
||||
allow_blank=False,
|
||||
allow_null=False,
|
||||
validators=[validate_no_spaces],
|
||||
),
|
||||
help_text="List of labels to be added to the ticket. Labels cannot contain spaces and must not exceed 255 characters.",
|
||||
)
|
||||
incident_category = serializers.CharField(
|
||||
max_length=128,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
suggested_team_routing = serializers.CharField(max_length=10, write_only=True)
|
||||
project = serializers.CharField(
|
||||
max_length=128,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
default="SBI",
|
||||
)
|
||||
priority = serializers.IntegerField(
|
||||
min_value=1,
|
||||
max_value=5,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
help_text="Priority level 1-5 (1=Critical, 2=High, 3=Medium, 4=Low, 5=Lowest)",
|
||||
)
|
||||
business_impact = serializers.ChoiceField(
|
||||
write_only=True, choices=["High", "Medium", "Low"], allow_null=True
|
||||
)
|
||||
environments = serializers.ListField(
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
default=["-"],
|
||||
child=serializers.ChoiceField(
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
choices=["INT", "STG", "PRD", "SUPPORT", "-"],
|
||||
),
|
||||
)
|
||||
attachments = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
)
|
||||
issue_type = serializers.ChoiceField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
default="Incident",
|
||||
choices=[
|
||||
"Incident",
|
||||
"Service Request",
|
||||
"Documentation/Process Request",
|
||||
"Feature Request",
|
||||
],
|
||||
)
|
||||
|
||||
def validate_labels(self, value: list[str] | None) -> list[str]:
|
||||
"""Transform null labels to empty list."""
|
||||
if value is None:
|
||||
return []
|
||||
return value
|
||||
|
||||
def validate_environments(self, value: list[str] | None) -> list[str] | Any:
|
||||
if not value:
|
||||
return self.fields["environments"].default
|
||||
return value
|
||||
|
||||
def create(self, validated_data: dict[str, Any]) -> JiraTicket:
|
||||
reporter_email: str = validated_data["reporter_email"]
|
||||
|
||||
reporter_user, reporter, user_domain = get_reporter_user_from_email(
|
||||
reporter_email
|
||||
)
|
||||
user_in_description: str = (
|
||||
f"\nReporter email {reporter_email}\n"
|
||||
if "manomano" not in user_domain
|
||||
else ""
|
||||
)
|
||||
|
||||
issue = jira_client.create_issue(
|
||||
issuetype=validated_data["issue_type"],
|
||||
summary=validated_data["summary"],
|
||||
description=f"""{validated_data.get("description")}\n
|
||||
\n
|
||||
🤖 This ticket was created through the Incident Verification Bot https://landbot.pro/v3/H-1332620-8BF395J9STSHL7IN/index.html.\n
|
||||
\n
|
||||
{user_in_description}
|
||||
\n
|
||||
""",
|
||||
assignee=None,
|
||||
reporter=reporter.id,
|
||||
labels=validated_data["labels"],
|
||||
priority=validated_data["priority"],
|
||||
seller_contract_id=validated_data["seller_contract_id"],
|
||||
zoho_desk_ticket_id=validated_data["zoho"],
|
||||
zendesk_ticket_id=validated_data.get("zendesk"),
|
||||
platform=validated_data["platform"],
|
||||
incident_category=validated_data["incident_category"],
|
||||
business_impact=validated_data["business_impact"],
|
||||
environments=validated_data["environments"],
|
||||
suggested_team_routing=validated_data["suggested_team_routing"],
|
||||
project=validated_data["project"],
|
||||
)
|
||||
issue_id = issue.get("id")
|
||||
if issue_id is None:
|
||||
logger.error("Could not create Jira ticket")
|
||||
raise JiraAPIError("Could not create Jira ticket")
|
||||
if validated_data["attachments"] is not None:
|
||||
# Prepare attachments and double check we don't have any empty strings
|
||||
attachments: list[str] = [
|
||||
a
|
||||
for a in validated_data["attachments"]
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("'", "")
|
||||
.split(", ")
|
||||
if a
|
||||
]
|
||||
jira_client.add_attachments_to_issue(issue_id, attachments)
|
||||
|
||||
jira_ticket = JiraTicket.objects.create(**issue)
|
||||
|
||||
# Send messages in the relevant Slack channels if needed and alert the reporter in DMs
|
||||
alert_slack_new_jira_ticket(
|
||||
jira_ticket, reporter_user=reporter_user, reporter_email=reporter_email
|
||||
)
|
||||
|
||||
return jira_ticket
|
||||
|
||||
class Meta:
|
||||
model = JiraTicket
|
||||
fields = [
|
||||
"summary",
|
||||
"description",
|
||||
"seller_contract_id",
|
||||
"zoho",
|
||||
"zendesk",
|
||||
"platform",
|
||||
"reporter_email",
|
||||
"incident_category",
|
||||
"labels",
|
||||
"environments",
|
||||
"issue_type",
|
||||
"business_impact",
|
||||
"priority",
|
||||
"id",
|
||||
"key",
|
||||
"attachments",
|
||||
"suggested_team_routing",
|
||||
"project",
|
||||
]
|
||||
|
||||
|
||||
class JiraWebhookUpdateSerializer(serializers.Serializer[Any]):
|
||||
issue = serializers.DictField()
|
||||
changelog = serializers.DictField()
|
||||
user = serializers.DictField()
|
||||
webhookEvent = serializers.ChoiceField( # noqa: N815
|
||||
write_only=True,
|
||||
choices=["jira:issue_updated"],
|
||||
)
|
||||
|
||||
def create(self, validated_data: dict[str, Any]) -> bool:
|
||||
changes = validated_data.get("changelog", {}).get("items") or []
|
||||
if not changes:
|
||||
logger.debug("Jira webhook had no changelog items; skipping.")
|
||||
return True
|
||||
|
||||
jira_ticket_key = validated_data["issue"].get("key")
|
||||
incident = (
|
||||
self._get_incident_from_jira_ticket(jira_ticket_key)
|
||||
if jira_ticket_key
|
||||
else None
|
||||
)
|
||||
if incident is None:
|
||||
# No linked incident: still emit Slack alerts for tracked fields (status/priority).
|
||||
for change_item in changes:
|
||||
field = (change_item.get("field") or "").lower()
|
||||
to_val = change_item.get("toString")
|
||||
from_val = change_item.get("fromString")
|
||||
is_status = field == "status"
|
||||
is_priority = (
|
||||
self._parse_priority_value(to_val) is not None
|
||||
or self._parse_priority_value(from_val) is not None
|
||||
)
|
||||
if not (is_status or is_priority):
|
||||
continue
|
||||
if not self._alert_slack_update(
|
||||
validated_data, jira_ticket_key, change_item
|
||||
):
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return True
|
||||
|
||||
for change_item in changes:
|
||||
if not self._sync_jira_fields_to_incident(
|
||||
validated_data, jira_ticket_key, incident, change_item
|
||||
):
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _alert_slack_update(
|
||||
validated_data: dict[str, Any],
|
||||
jira_ticket_key: str | None,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
ticket_id = validated_data["issue"].get("id")
|
||||
ticket_key = jira_ticket_key or ""
|
||||
jira_author_name = str(validated_data["user"].get("displayName") or "")
|
||||
jira_field_modified = str(change_item.get("field") or "")
|
||||
jira_field_from = str(change_item.get("fromString") or "")
|
||||
jira_field_to = str(change_item.get("toString") or "")
|
||||
|
||||
if ticket_id is None or ticket_key == "" or jira_field_modified == "":
|
||||
logger.error(
|
||||
"Missing required Jira changelog data: id=%s key=%s field=%s",
|
||||
ticket_id,
|
||||
jira_ticket_key,
|
||||
change_item.get("field"),
|
||||
)
|
||||
return False
|
||||
|
||||
status = alert_slack_update_ticket(
|
||||
jira_ticket_id=int(ticket_id),
|
||||
jira_ticket_key=ticket_key,
|
||||
jira_author_name=jira_author_name,
|
||||
jira_field_modified=jira_field_modified,
|
||||
jira_field_from=jira_field_from,
|
||||
jira_field_to=jira_field_to,
|
||||
)
|
||||
if status is not True:
|
||||
logger.error(
|
||||
"Could not alert in Slack for the update/s in the Jira ticket %s",
|
||||
jira_ticket_key or "unknown",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _sync_jira_fields_to_incident(
|
||||
self,
|
||||
validated_data: dict[str, Any],
|
||||
jira_ticket_key: str | None,
|
||||
incident: Incident,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
field = (change_item.get("field") or "").lower()
|
||||
|
||||
# Loop prevention: skip if this exact change was just sent Impact -> Jira
|
||||
if self._skip_due_to_recent_impact_change(jira_ticket_key, field, change_item):
|
||||
logger.debug(
|
||||
"Skipping Jira→Impact sync for %s on %s due to recent Impact change",
|
||||
field,
|
||||
jira_ticket_key,
|
||||
)
|
||||
return False
|
||||
|
||||
# Detect and apply status/priority updates only
|
||||
if field == "status":
|
||||
if not self._alert_slack_update(
|
||||
validated_data, jira_ticket_key, change_item
|
||||
):
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return self._handle_status_update(validated_data, incident, change_item)
|
||||
|
||||
to_val = change_item.get("toString")
|
||||
from_val = change_item.get("fromString")
|
||||
if (
|
||||
self._parse_priority_value(to_val) is not None
|
||||
or self._parse_priority_value(from_val) is not None
|
||||
):
|
||||
if not self._alert_slack_update(
|
||||
validated_data, jira_ticket_key, change_item
|
||||
):
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return self._handle_priority_update(validated_data, incident, change_item)
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_incident_from_jira_ticket(jira_ticket_key: str) -> Incident | None:
|
||||
try:
|
||||
jira_ticket = JiraTicket.objects.select_related("incident").get(
|
||||
key=jira_ticket_key
|
||||
)
|
||||
except JiraTicket.DoesNotExist:
|
||||
logger.warning(
|
||||
"Received Jira webhook for %s but no JiraTicket found; skipping",
|
||||
jira_ticket_key,
|
||||
)
|
||||
return None
|
||||
|
||||
incident = getattr(jira_ticket, "incident", None)
|
||||
if incident is None:
|
||||
logger.warning(
|
||||
"Jira ticket %s not linked to an incident; skipping sync",
|
||||
jira_ticket_key,
|
||||
)
|
||||
return None
|
||||
|
||||
return incident
|
||||
|
||||
def _handle_status_update(
|
||||
self,
|
||||
validated_data: dict[str, Any],
|
||||
incident: Incident,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
jira_status_to = change_item.get("toString") or ""
|
||||
jira_status_from = change_item.get("fromString") or ""
|
||||
impact_status = JIRA_TO_IMPACT_STATUS_MAP.get(jira_status_to)
|
||||
if impact_status is None:
|
||||
logger.debug(
|
||||
"Jira status '%s' has no Impact mapping; skipping incident sync",
|
||||
jira_status_to,
|
||||
)
|
||||
return True
|
||||
|
||||
# Guard against unintentional closures triggered by non-standard Jira workflows.
|
||||
# If the target maps to CLOSED but the previous Jira status is not a known Impact
|
||||
# status (e.g. "Code Review" from a CI/CD automation), this is likely an intermediate
|
||||
# step and not a deliberate incident closure. Only allow close when the transition
|
||||
# comes from a recognised Impact status (e.g. "Reporter validation").
|
||||
if impact_status == IncidentStatus.CLOSED and jira_status_from not in JIRA_TO_IMPACT_STATUS_MAP:
|
||||
logger.warning(
|
||||
"Skipping Jira→Impact close for incident %s: transition from unknown Jira status '%s' to 'Closed'. "
|
||||
"This looks like an intermediate step in a non-standard Jira workflow (e.g. CI/CD automation). "
|
||||
"Close the ticket from a recognised status (e.g. 'Reporter validation') to trigger Impact closure.",
|
||||
incident.id,
|
||||
jira_status_from,
|
||||
)
|
||||
return True
|
||||
|
||||
if incident.status == impact_status:
|
||||
logger.debug(
|
||||
"Incident %s already at status %s from Jira webhook; no-op",
|
||||
incident.id,
|
||||
incident.status.label,
|
||||
)
|
||||
return True
|
||||
|
||||
# Before closing, verify all business rules are satisfied (key events, status, etc.).
|
||||
# If not, notify the incident channel and leave the incident open.
|
||||
if impact_status == IncidentStatus.CLOSED:
|
||||
can_close, reasons = incident.can_be_closed
|
||||
if not can_close:
|
||||
logger.warning(
|
||||
"Jira ticket closed but Impact incident %s cannot be closed yet: %s",
|
||||
incident.id,
|
||||
reasons,
|
||||
)
|
||||
jira_ticket_key = validated_data.get("issue", {}).get("key", "")
|
||||
if hasattr(incident, "conversation") and jira_ticket_key:
|
||||
try:
|
||||
incident.conversation.send_message_and_save(
|
||||
SlackMessageJiraClosedBlockedByCanBeClosed(
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
reasons=reasons,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to notify incident channel for blocked close of incident %s",
|
||||
incident.id,
|
||||
)
|
||||
return True
|
||||
|
||||
incident.create_incident_update(
|
||||
created_by=None,
|
||||
status=impact_status,
|
||||
message=f"Status synced from Jira ({jira_status_to})",
|
||||
event_type="jira_status_sync",
|
||||
)
|
||||
return True
|
||||
|
||||
def _handle_priority_update(
|
||||
self,
|
||||
_validated_data: dict[str, Any],
|
||||
incident: Incident,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
jira_priority_to = change_item.get("toString") or ""
|
||||
|
||||
impact_priority_value = self._parse_priority_value(jira_priority_to)
|
||||
|
||||
if impact_priority_value is None:
|
||||
logger.debug(
|
||||
"Jira priority '%s' has no Impact mapping; skipping incident sync",
|
||||
jira_priority_to,
|
||||
)
|
||||
return True
|
||||
if incident.priority and incident.priority.value == impact_priority_value:
|
||||
logger.debug(
|
||||
"Incident %s already at priority %s from Jira webhook; no-op",
|
||||
incident.id,
|
||||
incident.priority.value,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
impact_priority = Priority.objects.get(value=impact_priority_value)
|
||||
except Priority.DoesNotExist:
|
||||
logger.warning(
|
||||
"No Impact priority with value %s; skipping Jira→Impact priority sync",
|
||||
impact_priority_value,
|
||||
)
|
||||
return True
|
||||
|
||||
incident.create_incident_update(
|
||||
created_by=None,
|
||||
priority_id=impact_priority.id,
|
||||
message=f"Priority synced from Jira ({jira_priority_to})",
|
||||
event_type="jira_priority_sync",
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _skip_due_to_recent_impact_change(
|
||||
jira_ticket_key: str | None, field: str, change_item: dict[str, Any]
|
||||
) -> bool:
|
||||
if not jira_ticket_key:
|
||||
return False
|
||||
try:
|
||||
jira_ticket = JiraTicket.objects.select_related("incident").get(
|
||||
key=jira_ticket_key
|
||||
)
|
||||
except JiraTicket.DoesNotExist:
|
||||
return False
|
||||
|
||||
incident = getattr(jira_ticket, "incident", None)
|
||||
if not incident:
|
||||
return False
|
||||
|
||||
raw_value = change_item.get("toString")
|
||||
# Normalize value consistently with Impact→Jira writes
|
||||
if field == "status":
|
||||
value_for_cache = raw_value
|
||||
else:
|
||||
parsed = JiraWebhookUpdateSerializer._parse_priority_value(raw_value)
|
||||
value_for_cache = parsed if parsed is not None else raw_value
|
||||
|
||||
cache_key = f"sync:impact_to_jira:{incident.id}:{field}:{normalize_cache_value(value_for_cache)}"
|
||||
if cache.get(cache_key):
|
||||
cache.delete(cache_key)
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_priority_value(raw: Any) -> int | None:
|
||||
"""Try to extract a priority value (1-5) from Jira changelog strings."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
parsed = int(raw)
|
||||
if 1 <= parsed <= 5:
|
||||
return parsed
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return JIRA_TO_IMPACT_PRIORITY_MAP.get(str(raw))
|
||||
|
||||
def update(self, instance: Any, validated_data: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class JiraWebhookCommentSerializer(serializers.Serializer[Any]):
|
||||
issue = serializers.DictField()
|
||||
comment = serializers.DictField()
|
||||
webhookEvent = serializers.ChoiceField( # noqa: N815
|
||||
write_only=True,
|
||||
choices=["comment_created", "comment_updated", "comment_deleted"],
|
||||
)
|
||||
|
||||
def create(self, validated_data: dict[str, Any]) -> bool:
|
||||
jira_ticket_key = validated_data["issue"].get("key")
|
||||
status = alert_slack_comment_ticket(
|
||||
webhook_event=validated_data["webhookEvent"],
|
||||
jira_ticket_id=validated_data["issue"].get("id"),
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
author_jira_name=validated_data["comment"].get("author").get("displayName"),
|
||||
comment=validated_data["comment"].get("body"),
|
||||
)
|
||||
if status is not True:
|
||||
logger.error(
|
||||
f"Could not alert in Slack for created/modified comment in the Jira ticket {jira_ticket_key}"
|
||||
)
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return True
|
||||
|
||||
def update(self, instance: Any, validated_data: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from firefighter.jira_app.client import (
|
||||
JiraAPIError,
|
||||
JiraUserNotFoundError,
|
||||
)
|
||||
from firefighter.jira_app.models import JiraUser
|
||||
from firefighter.raid.client import client as jira_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.raid.types import JiraObject
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID: str = settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
error_jira_ticket_creation = "Could not create Jira ticket"
|
||||
|
||||
|
||||
def check_issue_id(issue: JiraObject, title: str, reporter: str) -> int | str:
|
||||
"""Check and return the issue ID from a JiraObject.
|
||||
|
||||
Raises JiraAPIError if the issue ID is missing or invalid.
|
||||
"""
|
||||
issue_id = issue.get("id")
|
||||
if issue_id is None:
|
||||
logger.error(
|
||||
f"Could not create Jira ticket for the incident {title} and the reporter {reporter}"
|
||||
)
|
||||
raise JiraAPIError(error_jira_ticket_creation)
|
||||
return issue_id
|
||||
|
||||
|
||||
def get_jira_user_from_user(user: User) -> JiraUser:
|
||||
"""Returns the JiraUser object for a given user, if it exists in DB or can be fetched on Jira API. Returns the default JiraUser if not found."""
|
||||
try:
|
||||
jira_user = jira_client.get_jira_user_from_user(user)
|
||||
except JiraAPIError:
|
||||
logger.exception(f"Could not find Jira user for {user.id}")
|
||||
|
||||
logger.warning(f"User {user.id} has no Jira user")
|
||||
try:
|
||||
jira_user = jira_client.get_jira_user_from_jira_id(
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
)
|
||||
except JiraUserNotFoundError:
|
||||
logger.exception(
|
||||
f"Could not find Jira user with account id {RAID_DEFAULT_JIRA_QRAFT_USER_ID}"
|
||||
)
|
||||
jira_user = JiraUser.objects.get(id=RAID_DEFAULT_JIRA_QRAFT_USER_ID)
|
||||
return jira_user
|
||||
|
||||
|
||||
def create_issue_feature_request(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira issue of type Feature Request.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
"""
|
||||
if labels is None:
|
||||
labels = [""]
|
||||
if "feature-request" not in labels:
|
||||
labels.append("feature-request")
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Feature Request",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
def create_issue_documentation_request(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira issue of type Documentation/Process Request.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
"""
|
||||
if labels is None:
|
||||
labels = [""]
|
||||
if "documentation-request" not in labels:
|
||||
labels.append("documentation-request")
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Documentation/Process Request",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
def create_issue_internal(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
business_impact: str | None,
|
||||
team_to_be_routed: str | None,
|
||||
incident_category: str | None,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira Incident Issue of type Internal.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
business_impact (str): Business impact of the issue
|
||||
team_to_be_routed (str): Team to be routed
|
||||
incident_category (str): Incident category of the issue
|
||||
"""
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Incident",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
business_impact=business_impact,
|
||||
suggested_team_routing=team_to_be_routed,
|
||||
incident_category=incident_category,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomerIssueData:
|
||||
"""Data container for customer issue creation parameters."""
|
||||
priority: int | None
|
||||
labels: list[str] | None
|
||||
platform: str
|
||||
business_impact: str | None
|
||||
team_to_be_routed: str | None
|
||||
area: str | None
|
||||
zendesk_ticket_id: str | None
|
||||
incident_category: str | None = None
|
||||
|
||||
|
||||
def create_issue_customer(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
issue_data: CustomerIssueData,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira Incident issue of type Customer.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
issue_data (CustomerIssueData): Container with issue parameters
|
||||
"""
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Incident",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=issue_data.priority,
|
||||
labels=issue_data.labels,
|
||||
platform=issue_data.platform,
|
||||
business_impact=issue_data.business_impact,
|
||||
suggested_team_routing=issue_data.team_to_be_routed,
|
||||
zendesk_ticket_id=issue_data.zendesk_ticket_id,
|
||||
incident_category=issue_data.incident_category,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
def create_issue_seller( # noqa: PLR0913
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
business_impact: str | None,
|
||||
team_to_be_routed: str | None,
|
||||
incident_category: str | None,
|
||||
seller_contract_id: str | None,
|
||||
is_key_account: bool | None,
|
||||
is_seller_in_golden_list: bool | None,
|
||||
zoho_desk_ticket_id: str | None,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira Incident issue of type Seller.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
business_impact (str): Business impact of the issue
|
||||
team_to_be_routed (str): Team to be routed
|
||||
incident_category (str): Incident category of the issue
|
||||
seller_contract_id (str): Seller contract id
|
||||
is_key_account (bool): Is key account
|
||||
is_seller_in_golden_list (bool): Is seller in golden list
|
||||
zoho_desk_ticket_id (str): Zoho desk ticket id
|
||||
"""
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Incident",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
business_impact=business_impact,
|
||||
suggested_team_routing=team_to_be_routed,
|
||||
seller_contract_id=seller_contract_id,
|
||||
is_key_account=is_key_account,
|
||||
is_seller_in_golden_list=is_seller_in_golden_list,
|
||||
zoho_desk_ticket_id=zoho_desk_ticket_id,
|
||||
incident_category=incident_category,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
Whitespace-only changes.
+382
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from firefighter.incidents.enums import IncidentStatus
|
||||
from firefighter.incidents.models.incident import Incident
|
||||
from firefighter.incidents.signals import incident_updated
|
||||
from firefighter.raid.client import RAID_JIRA_WORKFLOW_NAME, client
|
||||
from firefighter.raid.utils import normalize_cache_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.incident_update import IncidentUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
JIRA_SYNC_CACHE_TIMEOUT = getattr(settings, "JIRA_SYNC_CACHE_TIMEOUT", 60)
|
||||
|
||||
JIRA_STATUS_INCOMING = "Incoming"
|
||||
JIRA_STATUS_PENDING_RESOLUTION = "Pending resolution"
|
||||
JIRA_STATUS_IN_PROGRESS = "in progress"
|
||||
JIRA_STATUS_REPORTER_VALIDATION = "Reporter validation"
|
||||
JIRA_STATUS_CLOSED = "Closed"
|
||||
|
||||
IMPACT_TO_JIRA_STATUS_MAP: dict[IncidentStatus, str] = {
|
||||
IncidentStatus.OPEN: JIRA_STATUS_INCOMING,
|
||||
IncidentStatus.INVESTIGATING: JIRA_STATUS_IN_PROGRESS,
|
||||
IncidentStatus.MITIGATING: JIRA_STATUS_IN_PROGRESS,
|
||||
IncidentStatus.MITIGATED: JIRA_STATUS_REPORTER_VALIDATION,
|
||||
IncidentStatus.POST_MORTEM: JIRA_STATUS_REPORTER_VALIDATION,
|
||||
IncidentStatus.CLOSED: JIRA_STATUS_CLOSED,
|
||||
}
|
||||
|
||||
|
||||
def _set_impact_to_jira_cache(
|
||||
incident_id: Any, field: str, value: Any, timeout: int = JIRA_SYNC_CACHE_TIMEOUT
|
||||
) -> None:
|
||||
cache_key = (
|
||||
f"sync:impact_to_jira:{incident_id}:{field}:{normalize_cache_value(value)}"
|
||||
)
|
||||
cache.set(cache_key, value=True, timeout=timeout)
|
||||
|
||||
|
||||
@receiver(signal=incident_updated, sender="update_status")
|
||||
def incident_updated_close_ticket_when_mitigated_or_postmortem(
|
||||
sender: Any,
|
||||
incident: Incident,
|
||||
incident_update: IncidentUpdate,
|
||||
updated_fields: list[str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Close Jira incident ticket based on incident status and priority.
|
||||
|
||||
Closure logic:
|
||||
- P1/P2 (needs_postmortem): Close only when incident is CLOSED
|
||||
- P3+ (no postmortem): Close when incident is MITIGATED or CLOSED
|
||||
- POST_MORTEM status never closes the ticket (it remains open during PM phase)
|
||||
"""
|
||||
logger.debug(
|
||||
"incident_updated handler invoked for incident #%s with status %s; updated_fields=%s event_type=%s",
|
||||
getattr(incident, "id", "unknown"),
|
||||
incident_update.status,
|
||||
updated_fields,
|
||||
incident_update.event_type,
|
||||
)
|
||||
# Skip if this update was produced by Jira webhook sync to avoid redundant close calls
|
||||
if incident_update.event_type == "jira_status_sync":
|
||||
logger.debug(
|
||||
"Skipping Jira transition: incident #%s update came from Jira (event_type=jira_status_sync)",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
if "_status" not in updated_fields:
|
||||
logger.debug(
|
||||
"Skipping Jira transition: incident #%s update lacks _status in updated_fields (%s)",
|
||||
getattr(incident, "id", "unknown"),
|
||||
updated_fields,
|
||||
)
|
||||
return
|
||||
|
||||
if not hasattr(incident, "jira_ticket") or incident.jira_ticket is None:
|
||||
logger.warning(
|
||||
"Trying to close Jira ticket for incident %s but no Jira ticket found",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
# Special case: when Impact moves to INVESTIGATING or MITIGATING, Jira must go
|
||||
# through two steps: "Pending resolution" then "in progress". We also set
|
||||
# cache keys for both steps to prevent the Jira→Impact webhook from bouncing
|
||||
# Impact back to OPEN on the intermediate hop.
|
||||
if incident_update.status in (IncidentStatus.INVESTIGATING, IncidentStatus.MITIGATING):
|
||||
steps = (JIRA_STATUS_PENDING_RESOLUTION, JIRA_STATUS_IN_PROGRESS)
|
||||
incident_id = getattr(incident, "id", None)
|
||||
if incident_id is not None:
|
||||
for step in steps:
|
||||
_set_impact_to_jira_cache(incident_id, "status", step)
|
||||
|
||||
all_steps_succeeded = True
|
||||
for step in steps:
|
||||
try:
|
||||
logger.debug(
|
||||
"Transitioning Jira ticket %s via workflow %s to status %s (incident #%s, impact status %s)",
|
||||
incident.jira_ticket.id,
|
||||
RAID_JIRA_WORKFLOW_NAME,
|
||||
step,
|
||||
getattr(incident, "id", "unknown"),
|
||||
incident_update.status,
|
||||
)
|
||||
client.transition_issue_auto(
|
||||
incident.jira_ticket.id, step, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
except Exception:
|
||||
all_steps_succeeded = False
|
||||
logger.exception(
|
||||
"Failed to transition Jira ticket %s to %s for incident %s",
|
||||
incident.jira_ticket.id,
|
||||
step,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
if all_steps_succeeded:
|
||||
logger.info(
|
||||
"Transitioned Jira ticket %s through Pending resolution -> in progress from Impact status %s",
|
||||
incident.jira_ticket.id,
|
||||
incident_update.status.label if incident_update.status else "Unknown",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"At least one Jira transition failed while moving ticket %s to MITIGATING (incident #%s)",
|
||||
incident.jira_ticket.id,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
# Decide target Jira status based on Impact status and postmortem requirement.
|
||||
# P3+ (no postmortem): close Jira when Impact reaches MITIGATED or CLOSED.
|
||||
# P1/P2 (needs_postmortem): close Jira only when Impact reaches CLOSED.
|
||||
incident_status = incident_update.status
|
||||
if incident_status is None:
|
||||
logger.info(
|
||||
"Skipping Jira transition: incident #%s status is None",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
target_jira_status: str | None = (
|
||||
JIRA_STATUS_CLOSED
|
||||
if incident_status == IncidentStatus.CLOSED
|
||||
else IMPACT_TO_JIRA_STATUS_MAP.get(incident_status)
|
||||
)
|
||||
|
||||
if target_jira_status is None:
|
||||
logger.info(
|
||||
"Skipping Jira transition: no Jira status mapping for Impact status %s (incident #%s)",
|
||||
incident_update.status,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
incident_id = getattr(incident, "id", None)
|
||||
if incident_id is not None:
|
||||
_set_impact_to_jira_cache(incident_id, "status", target_jira_status)
|
||||
logger.debug(
|
||||
"Transitioning Jira ticket %s via workflow %s to status %s (incident #%s, impact status %s)",
|
||||
incident.jira_ticket.id,
|
||||
RAID_JIRA_WORKFLOW_NAME,
|
||||
target_jira_status,
|
||||
getattr(incident, "id", "unknown"),
|
||||
incident_update.status,
|
||||
)
|
||||
client.transition_issue_auto(
|
||||
incident.jira_ticket.id, target_jira_status, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
logger.info(
|
||||
"Transitioned Jira ticket %s to %s from Impact status %s",
|
||||
incident.jira_ticket.id,
|
||||
target_jira_status,
|
||||
incident_update.status.label if incident_update.status else "Unknown",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to transition Jira ticket %s to %s for incident %s",
|
||||
incident.jira_ticket.id,
|
||||
target_jira_status,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
|
||||
|
||||
# Listen to all incident_updated signals so both UI (update_status) and API/admin paths trigger
|
||||
@receiver(signal=incident_updated)
|
||||
def incident_updated_sync_priority_to_jira(
|
||||
sender: Any,
|
||||
incident: Incident,
|
||||
incident_update: IncidentUpdate,
|
||||
updated_fields: list[str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Push Impact priority changes to Jira custom priority field (customfield_11064).
|
||||
Skips if change originated from Jira (event_type='jira_priority_sync') to avoid loops.
|
||||
"""
|
||||
logger.debug(
|
||||
"Priority sync handler invoked: incident #%s sender=%s updated_fields=%s event_type=%s",
|
||||
getattr(incident, "id", "unknown"),
|
||||
sender,
|
||||
updated_fields,
|
||||
incident_update.event_type,
|
||||
)
|
||||
|
||||
if incident_update.event_type == "jira_priority_sync":
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s update came from Jira (event_type=jira_priority_sync)",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
if "priority_id" not in updated_fields:
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s update lacks priority_id in updated_fields (%s) sender=%s",
|
||||
getattr(incident, "id", "unknown"),
|
||||
updated_fields,
|
||||
sender,
|
||||
)
|
||||
return
|
||||
|
||||
if not hasattr(incident, "jira_ticket") or incident.jira_ticket is None:
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s has no Jira ticket",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
if not incident.priority:
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s priority is missing",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
incident_id = getattr(incident, "id", None)
|
||||
if incident_id is not None:
|
||||
_set_impact_to_jira_cache(incident_id, "priority", incident.priority.value)
|
||||
client.update_issue_fields(
|
||||
incident.jira_ticket.id,
|
||||
customfield_11064={"value": str(incident.priority.value)},
|
||||
)
|
||||
logger.info(
|
||||
"Synced priority %s to Jira ticket %s (customfield_11064) for incident #%s",
|
||||
incident.priority.value,
|
||||
incident.jira_ticket.id,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to sync priority %s to Jira ticket %s for incident %s",
|
||||
incident.priority.value,
|
||||
incident.jira_ticket.id,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
|
||||
|
||||
# Fallback: if an Incident save bypasses incident_updated (e.g., admin inline), push priority anyway.
|
||||
@receiver(post_save, sender=Incident)
|
||||
def incident_priority_post_save_fallback(
|
||||
sender: Any,
|
||||
instance: Incident,
|
||||
*,
|
||||
created: bool,
|
||||
update_fields: set[str] | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Fallback to push priority to Jira when Incident saves with priority_id in update_fields
|
||||
but no incident_updated signal fired (e.g., admin edits). Skips when marked to avoid loops.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
if update_fields and "priority_id" not in update_fields:
|
||||
return
|
||||
if getattr(instance, "_skip_priority_sync", False):
|
||||
logger.debug(
|
||||
"Skipping post_save priority sync for incident #%s due to skip flag",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
if not hasattr(instance, "jira_ticket") or instance.jira_ticket is None:
|
||||
logger.debug(
|
||||
"Skipping post_save priority sync: incident #%s has no Jira ticket",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
if not instance.priority:
|
||||
logger.debug(
|
||||
"Skipping post_save priority sync: incident #%s priority missing",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
_set_impact_to_jira_cache(instance.id, "priority", instance.priority.value)
|
||||
client.update_issue_fields(
|
||||
instance.jira_ticket.id,
|
||||
customfield_11064={"value": str(instance.priority.value)},
|
||||
)
|
||||
logger.info(
|
||||
"Post-save synced priority %s to Jira ticket %s (customfield_11064) for incident #%s",
|
||||
instance.priority.value,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed post-save priority sync %s to Jira ticket %s for incident %s",
|
||||
instance.priority.value,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Incident)
|
||||
def incident_status_post_save_fallback(
|
||||
sender: Any,
|
||||
instance: Incident,
|
||||
*,
|
||||
created: bool,
|
||||
update_fields: set[str] | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Fallback to push status to Jira when Incident saves with status in update_fields
|
||||
but no incident_updated signal fired (e.g., admin edits). Skips when marked to avoid loops.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
if (
|
||||
update_fields
|
||||
and "_status" not in update_fields
|
||||
and "status" not in update_fields
|
||||
):
|
||||
return
|
||||
if getattr(instance, "_skip_status_sync", False):
|
||||
logger.debug(
|
||||
"Skipping post_save status sync for incident #%s due to skip flag",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
if not hasattr(instance, "jira_ticket") or instance.jira_ticket is None:
|
||||
logger.debug(
|
||||
"Skipping post_save status sync: incident #%s has no Jira ticket",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
target_jira_status = IMPACT_TO_JIRA_STATUS_MAP.get(instance.status)
|
||||
if target_jira_status is None:
|
||||
logger.debug(
|
||||
"Skipping post_save status sync: no Jira mapping for status %s (incident #%s)",
|
||||
instance.status,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
try:
|
||||
_set_impact_to_jira_cache(instance.id, "status", target_jira_status)
|
||||
client.transition_issue_auto(
|
||||
instance.jira_ticket.id, target_jira_status, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
logger.info(
|
||||
"Post-save synced status %s to Jira ticket %s for incident #%s",
|
||||
instance.status,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed post-save status sync %s to Jira ticket %s for incident %s",
|
||||
instance.status,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
+1
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
_JiraId = int | str
|
||||
_Key = str
|
||||
_AssigneeId = str
|
||||
_ReporterId = str
|
||||
_Description = str
|
||||
_Summary = str
|
||||
_BusinessImpact = str
|
||||
|
||||
|
||||
class JiraObject(TypedDict):
|
||||
id: NotRequired[_JiraId]
|
||||
key: NotRequired[_Key]
|
||||
assignee_id: _AssigneeId
|
||||
reporter_id: _ReporterId
|
||||
description: _Description
|
||||
summary: _Summary
|
||||
issue_type: str
|
||||
project_key: str
|
||||
business_impact: _BusinessImpact
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.apps import apps
|
||||
from django.urls import path
|
||||
|
||||
from firefighter.api.urls import urlpatterns as api_urlspatterns
|
||||
from firefighter.raid import views
|
||||
|
||||
# Patch `api` app router, if installed
|
||||
if apps.is_installed("firefighter.api"):
|
||||
api_urlspatterns.append(
|
||||
path(
|
||||
"raid/jira_bot",
|
||||
views.CreateJiraBotView.as_view(), # pyright: ignore[reportGeneralTypeIssues]
|
||||
name="jira_bot",
|
||||
),
|
||||
)
|
||||
|
||||
api_urlspatterns.append(
|
||||
path(
|
||||
"raid/jira_comment",
|
||||
views.JiraCommentAlertView.as_view(), # pyright: ignore[reportGeneralTypeIssues]
|
||||
name="jira_comment",
|
||||
),
|
||||
)
|
||||
|
||||
api_urlspatterns.append(
|
||||
path(
|
||||
"raid/jira_update",
|
||||
views.JiraUpdateAlertView.as_view(), # pyright: ignore[reportGeneralTypeIssues]
|
||||
name="jira_update",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
def get_domain_from_email(email: str) -> str:
|
||||
"""Returns the domain from an email address.
|
||||
|
||||
Removes any subdomain(s) and the @, applies .lower().
|
||||
|
||||
Args:
|
||||
email: The email address to extract the domain from.
|
||||
|
||||
Returns:
|
||||
The domain part of the email address.
|
||||
|
||||
Raises:
|
||||
ValueError: If the email is not well-formed.
|
||||
|
||||
Examples:
|
||||
- `john.doe@example.com` => `example.com`
|
||||
- `alice.bob@test.example.ORG` => `example.org`
|
||||
- `webmaster@localhost` => `localhost`
|
||||
"""
|
||||
# If there is not exactly one @, the email is invalid
|
||||
if email.count("@") != 1:
|
||||
msg = f"Invalid email: {email}"
|
||||
raise ValueError(msg)
|
||||
domain = email.rsplit("@", maxsplit=1)[-1]
|
||||
if not domain:
|
||||
msg = f"Invalid email: {email}"
|
||||
raise ValueError(msg)
|
||||
domain_parts = domain.split(".")
|
||||
|
||||
return (".".join(domain_parts[-2:]) if len(domain_parts) > 2 else domain).lower()
|
||||
|
||||
|
||||
def normalize_cache_value(value: Any) -> str:
|
||||
"""Normalize cache values for loop-prevention keys."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower()
|
||||
try:
|
||||
return str(int(value))
|
||||
except (TypeError, ValueError):
|
||||
return str(value).strip().lower()
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Final, Never
|
||||
|
||||
from django.conf import settings
|
||||
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||
from rest_framework import generics, mixins, permissions, status
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
|
||||
from firefighter.raid.models import JiraTicket
|
||||
from firefighter.raid.serializers import (
|
||||
JiraWebhookCommentSerializer,
|
||||
JiraWebhookUpdateSerializer,
|
||||
LandbotIssueRequestSerializer,
|
||||
)
|
||||
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID: Final[str] = settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.request import Request
|
||||
|
||||
|
||||
@extend_schema(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Create an issue",
|
||||
summary="Create an issue",
|
||||
description="Example of a working request coming from landbot. be careful, as you probably don't have a john.doe user and thus may fallback to the default user.",
|
||||
value={
|
||||
"summary": "Swagger test",
|
||||
"description": "Description test where you want to depict your issue",
|
||||
"seller_contract_id": "12345678",
|
||||
"zoho": "https://crmplus.zoho.eu/mycrmlink/index.do/cxapp/agent/mycompany/all/tickets/details/123456789",
|
||||
"zendesk": "12345",
|
||||
"platform": "FR",
|
||||
"reporter_email": "john.doe@mycompany.com",
|
||||
"incident_category": "Payment Processing",
|
||||
"project": "SBI",
|
||||
"labels": ["originBot", "ProductsMerge"],
|
||||
"environments": ["PRD", "STG"],
|
||||
"issue_type": "Incident",
|
||||
"business_impact": "High",
|
||||
"priority": 4,
|
||||
"attachments": [
|
||||
"https://storage.googleapis.com/media.landbot.io/123456/customers/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456.png",
|
||||
"https://storage.googleapis.com/media.landbot.io/123456/customers/123456780/ABCDEFGHIJKLMNOPQRSTUVWXYZ123450.png",
|
||||
],
|
||||
},
|
||||
request_only=True, # signal that example only applies to requests
|
||||
response_only=False, # signal that example only applies to responses
|
||||
),
|
||||
OpenApiExample(
|
||||
"Create incident response",
|
||||
status_codes=["201"],
|
||||
value={"https://mycompany.atlassian.net/browse/1234567"},
|
||||
request_only=False,
|
||||
response_only=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
class CreateJiraBotView(
|
||||
mixins.CreateModelMixin,
|
||||
generics.GenericAPIView[JiraTicket],
|
||||
):
|
||||
queryset = JiraTicket.objects.all().select_related(
|
||||
"assignee",
|
||||
"reporter",
|
||||
"incident",
|
||||
"jiraissue_ptr",
|
||||
)
|
||||
serializer_class = LandbotIssueRequestSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
renderer_classes = [JSONRenderer]
|
||||
|
||||
def post(self, request: Request, *args: Never, **kwargs: Never) -> Response:
|
||||
"""Allow to create a Jira ticket through Landbot.
|
||||
Requires a valid Bearer token, that you can create in the back-office if you have the right permissions.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data.get("key"), status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
|
||||
class JiraUpdateAlertView(
|
||||
generics.CreateAPIView[Any],
|
||||
):
|
||||
serializer_class = JiraWebhookUpdateSerializer
|
||||
# XXX: Work on webhook token for authentication_classes
|
||||
authentication_classes = []
|
||||
permission_classes = [permissions.AllowAny]
|
||||
renderer_classes = [JSONRenderer]
|
||||
|
||||
def post(self, request: Request, *args: Never, **kwargs: Never) -> Response:
|
||||
"""Allow to send a message in Slack when some fields ("Priority", "project", "description", "status") of a Jira ticket are updated.
|
||||
Requires a valid Bearer token, that you can create in the back-office if you have the right permissions.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
class JiraCommentAlertView(
|
||||
generics.CreateAPIView[Any],
|
||||
):
|
||||
serializer_class = JiraWebhookCommentSerializer
|
||||
# XXX: Work on webhook token for authentication_classes
|
||||
authentication_classes = []
|
||||
permission_classes = [permissions.AllowAny]
|
||||
renderer_classes = [JSONRenderer]
|
||||
|
||||
def post(self, request: Request, *args: Never, **kwargs: Never) -> Response:
|
||||
"""Allow to send a message in Slack when a comment in a Jira ticket is created or modified.
|
||||
Requires a valid Bearer token, that you can create in the back-office if you have the right permissions.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response()
|
||||
Reference in new issue
Block a user