T3MP3ST public launch build

This commit is contained in:
t3mp3st contributors committed 2026-07-03 10:57:20 -04:00
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."
),
]
@@ -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",
),
),
]
@@ -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",
),
]
@@ -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",
),
]
@@ -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")
@@ -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
@@ -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"),
)
@@ -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()
@@ -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()