134 lines
4.9 KiB
Python
134 lines
4.9 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ReplyPier
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
|
||
|
|
One-way Jira escalation for native tickets: when enabled, tickets at or
|
||
|
|
above the configured priority get a mirrored Jira issue (external_ref_*).
|
||
|
|
Native ReplyPier stays the source of truth — nothing syncs back.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
from app.core.logger import get_logger
|
||
|
|
from app.models.jira import AgentJiraConfig, JiraToken
|
||
|
|
from app.models.organization import Organization
|
||
|
|
from app.models.ticket import Ticket
|
||
|
|
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||
|
|
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
_PRIORITY_ORDER = {"urgent": 0, "high": 1, "medium": 2, "low": 3}
|
||
|
|
|
||
|
|
|
||
|
|
def _find_project_config(db, ticket: Ticket) -> Optional[AgentJiraConfig]:
|
||
|
|
"""Project/issue-type to escalate into: the ticket's agent's Jira config
|
||
|
|
when enabled, else any enabled agent config in the organization."""
|
||
|
|
from app.models.agent import Agent
|
||
|
|
|
||
|
|
if ticket.agent_id is not None:
|
||
|
|
config = (
|
||
|
|
db.query(AgentJiraConfig)
|
||
|
|
.filter(
|
||
|
|
AgentJiraConfig.agent_id == str(ticket.agent_id),
|
||
|
|
AgentJiraConfig.enabled == True, # noqa: E712
|
||
|
|
AgentJiraConfig.project_key.isnot(None),
|
||
|
|
)
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
if config is not None:
|
||
|
|
return config
|
||
|
|
return (
|
||
|
|
db.query(AgentJiraConfig)
|
||
|
|
.join(Agent, Agent.id == AgentJiraConfig.agent_id.cast(Agent.id.type))
|
||
|
|
.filter(
|
||
|
|
Agent.organization_id == ticket.organization_id,
|
||
|
|
AgentJiraConfig.enabled == True, # noqa: E712
|
||
|
|
AgentJiraConfig.project_key.isnot(None),
|
||
|
|
)
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def should_escalate(ticket: Ticket, settings_row) -> bool:
|
||
|
|
if not settings_row.jira_escalation_enabled:
|
||
|
|
return False
|
||
|
|
if ticket.external_ref_id: # already escalated — one-way, never re-synced
|
||
|
|
return False
|
||
|
|
minimum = str(settings_row.jira_escalation_priority or "urgent")
|
||
|
|
return _PRIORITY_ORDER.get(str(ticket.priority), 99) <= _PRIORITY_ORDER.get(minimum, 0)
|
||
|
|
|
||
|
|
|
||
|
|
async def maybe_escalate_to_jira(db, service, ticket: Ticket, settings_row) -> bool:
|
||
|
|
"""Escalate when policy matches and the org has Jira connected. Failures
|
||
|
|
are logged, never raised — escalation must not break ticket flows."""
|
||
|
|
if not should_escalate(ticket, settings_row):
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
token = (
|
||
|
|
db.query(JiraToken)
|
||
|
|
.filter(JiraToken.organization_id == ticket.organization_id)
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
if token is None:
|
||
|
|
return False
|
||
|
|
config = _find_project_config(db, ticket)
|
||
|
|
if config is None:
|
||
|
|
logger.info(
|
||
|
|
f"{ticket.display_number}: Jira escalation enabled but no agent "
|
||
|
|
"has a Jira project configured"
|
||
|
|
)
|
||
|
|
return False
|
||
|
|
|
||
|
|
from app.api.jira import CreateJiraIssueModel
|
||
|
|
from app.services.jira import JiraService
|
||
|
|
|
||
|
|
description_parts = [
|
||
|
|
f"Escalated from ReplyPier ticket {ticket.display_number} "
|
||
|
|
f"(priority: {ticket.priority}, severity: {ticket.severity or 'n/a'}).",
|
||
|
|
]
|
||
|
|
if ticket.ai_summary:
|
||
|
|
description_parts.append(f"AI summary: {ticket.ai_summary}")
|
||
|
|
if ticket.description:
|
||
|
|
description_parts.append(ticket.description[:4000])
|
||
|
|
|
||
|
|
organization = db.query(Organization).filter(
|
||
|
|
Organization.id == ticket.organization_id
|
||
|
|
).first()
|
||
|
|
result = await JiraService().create_issue(
|
||
|
|
organization,
|
||
|
|
db,
|
||
|
|
CreateJiraIssueModel(
|
||
|
|
projectKey=config.project_key,
|
||
|
|
issueTypeId=config.issue_type_id,
|
||
|
|
summary=f"[{ticket.display_number}] {ticket.title}"[:250],
|
||
|
|
description="\n\n".join(description_parts),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
issue_key = result.get("key")
|
||
|
|
if not issue_key:
|
||
|
|
return False
|
||
|
|
ticket.external_ref_type = "jira"
|
||
|
|
ticket.external_ref_id = issue_key
|
||
|
|
ticket.external_ref_url = f"{token.site_url}/browse/{issue_key}"
|
||
|
|
service._add_activity(
|
||
|
|
ticket,
|
||
|
|
TicketActivityType.JIRA_ESCALATED,
|
||
|
|
actor_type=TicketActorType.SYSTEM,
|
||
|
|
body=f"Escalated to Jira as {issue_key}",
|
||
|
|
metadata={"issue_key": issue_key, "url": ticket.external_ref_url},
|
||
|
|
)
|
||
|
|
return True
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Jira escalation failed for {ticket.display_number}: {e}")
|
||
|
|
return False
|