Files

726 lines
33 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.
"""
from typing import Optional
from agno.tools import Toolkit
from app.core.config import settings
from app.core.logger import get_logger
from app.services.jira import (
JiraService,
jira_accessible_resources_url,
jira_rest_url,
jira_token_url,
)
from app.repositories.session_to_agent import SessionToAgentRepository
from app.api.jira import CreateJiraIssueModel
from app.database import SessionLocal
from app.models.organization import Organization
from app.models.agent import Agent
from uuid import UUID
logger = get_logger(__name__)
class JiraTools(Toolkit):
def __init__(self, agent_id: str, org_id: str, session_id: str):
super().__init__(name="jira_tools")
self.agent_id = agent_id
self.org_id = org_id
self.session_id = session_id
self.jira_service = JiraService()
# Register the functions
self.register(self.create_jira_ticket)
self.register(self.get_ticket_status)
self.register(self.check_existing_ticket)
def create_jira_ticket(
self,
summary: str,
description: str,
priority: Optional[str] = "Medium"
) -> str:
"""
Create a Jira ticket for the current session or update an existing one.
Args:
summary (str): The summary/title of the ticket.
description (str): The detailed description of the ticket.
priority (str, optional): The priority level of the ticket (Highest, High, Medium, Low, Lowest). Defaults to "Medium".
Returns:
str: JSON string with information about the created or updated ticket including ticket ID and status.
"""
try:
import json
import requests
from datetime import datetime, timedelta
# Check if a ticket already exists for this session
existing_ticket_str = self.check_existing_ticket()
existing_ticket = json.loads(existing_ticket_str)
existing_ticket_id = None
is_update = False
if existing_ticket.get("exists", False):
existing_ticket_id = existing_ticket.get("ticket_id")
is_update = True
logger.info(f"Found existing ticket {existing_ticket_id} for session {self.session_id}, will update it")
# Use context manager for database operations
with SessionLocal() as db:
# Get the agent's Jira configuration
try:
agent_uuid = UUID(str(self.agent_id))
agent = db.query(Agent).filter(Agent.id == agent_uuid).first()
except (ValueError, TypeError) as e:
logger.error(f"Invalid agent ID format: {e}")
return json.dumps({
"success": False,
"message": f"Invalid agent ID format: {str(e)}"
})
if not agent:
return json.dumps({
"success": False,
"message": "Agent not found"
})
# Get the organization
try:
org_uuid = UUID(str(self.org_id))
organization = db.query(Organization).filter(
Organization.id == org_uuid
).first()
except (ValueError, TypeError) as e:
logger.error(f"Invalid organization ID format: {e}")
return json.dumps({
"success": False,
"message": f"Invalid organization ID format: {str(e)}"
})
if not organization:
return json.dumps({
"success": False,
"message": "Organization not found"
})
# Get the agent's Jira configuration
from app.models.jira import AgentJiraConfig
jira_config = db.query(AgentJiraConfig).filter(
AgentJiraConfig.agent_id == str(self.agent_id)
).first()
if not jira_config or not jira_config.enabled:
return json.dumps({
"success": False,
"message": "Jira integration is not enabled for this agent"
})
# Create the issue data
issue_data = CreateJiraIssueModel(
projectKey=jira_config.project_key,
issueTypeId=jira_config.issue_type_id,
summary=summary,
description=description,
priority=priority,
chatId=str(self.session_id) # Ensure session_id is a string
)
# Get the Jira token
from app.models.jira import JiraToken
token = db.query(JiraToken).filter(
JiraToken.organization_id == organization.id
).first()
if not token:
return json.dumps({
"success": False,
"message": "No Jira connection found"
})
# Check if token is valid and refresh if needed
is_valid = self.jira_service.validate_token(token)
if not is_valid:
# Implement synchronous token refresh
try:
client_id = settings.JIRA_CLIENT_ID
client_secret = settings.JIRA_CLIENT_SECRET
if not client_id or not client_secret:
return json.dumps({
"success": False,
"message": "Jira client credentials not configured"
})
# Refresh token using requests
refresh_data = {
"grant_type": "refresh_token",
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": token.refresh_token
}
refresh_response = requests.post(
jira_token_url(),
data=refresh_data
)
if refresh_response.status_code != 200:
logger.error(f"Failed to refresh token: {refresh_response.text}")
return json.dumps({
"success": False,
"message": f"Failed to refresh Jira token: {refresh_response.text}"
})
# Parse response and update token
token_data = refresh_response.json()
# Update token in database
token.access_token = token_data["access_token"]
token.refresh_token = token_data.get("refresh_token", token.refresh_token)
token.token_type = token_data["token_type"]
token.expires_at = datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
db.commit()
logger.info("Successfully refreshed Jira token")
except Exception as e:
logger.error(f"Error refreshing Jira token: {e}")
return json.dumps({
"success": False,
"message": f"Error refreshing Jira token: {str(e)}"
})
# Create the issue directly using the REST API
headers = {
"Authorization": f"Bearer {token.access_token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
# Check if priority field is available for this project and issue type
priority_available = False
try:
# Get the create metadata to check available fields
create_meta_url = jira_rest_url(str(token.cloud_id), f"issue/createmeta?projectKeys={issue_data.projectKey}&issuetypeIds={issue_data.issueTypeId}&expand=projects.issuetypes.fields")
meta_response = requests.get(create_meta_url, headers=headers)
if meta_response.status_code == 200:
meta_data = meta_response.json()
# Navigate through the response to find the priority field
if meta_data.get("projects") and len(meta_data["projects"]) > 0:
project = meta_data["projects"][0]
if project.get("issuetypes") and len(project["issuetypes"]) > 0:
issue_type = project["issuetypes"][0]
if issue_type.get("fields") and "priority" in issue_type["fields"]:
priority_available = True
logger.info("Priority field is available for this project and issue type")
except Exception as e:
logger.warning(f"Failed to check if priority field is available: {e}")
# Continue without setting priority
# Prepare the issue data for the API
api_data = {
"fields": {
"summary": issue_data.summary,
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": issue_data.description
}
]
}
]
}
}
}
# Only include project and issuetype for new tickets, not updates
if not is_update:
api_data["fields"]["project"] = {"key": issue_data.projectKey}
api_data["fields"]["issuetype"] = {"id": issue_data.issueTypeId}
# Add priority if provided and available
if priority_available and issue_data.priority:
# Map string priority to Jira priority
priority_map = {
"Highest": "1",
"High": "2",
"Medium": "3",
"Low": "4",
"Lowest": "5"
}
priority_id = priority_map.get(issue_data.priority, "3") # Default to Medium
api_data["fields"]["priority"] = {"id": priority_id}
logger.info(f"Setting priority to {issue_data.priority} (ID: {priority_id})")
else:
logger.info("Priority field is not available or not provided, skipping")
try:
if is_update:
# Get the existing ticket details first to get the current description
existing_ticket_url = jira_rest_url(str(token.cloud_id), f"issue/{existing_ticket_id}")
existing_ticket_response = requests.get(existing_ticket_url, headers=headers)
if existing_ticket_response.status_code == 200:
existing_ticket_data = existing_ticket_response.json()
existing_description = ""
# Extract the existing description text
if existing_ticket_data.get("fields", {}).get("description"):
description_field = existing_ticket_data["fields"]["description"]
if isinstance(description_field, dict) and description_field.get("content"):
# Extract text from Atlassian Document Format
for content in description_field.get("content", []):
if content.get("type") == "paragraph" and content.get("content"):
for text_node in content.get("content", []):
if text_node.get("type") == "text":
existing_description += text_node.get("text", "")
existing_description += "\n\n"
elif isinstance(description_field, str):
existing_description = description_field
# Append the new description to the existing one
combined_description = existing_description.strip()
if combined_description:
combined_description += "\n\n--- Update " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + " ---\n\n"
combined_description += description
# Update the description in the API data
api_data["fields"]["description"] = {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": combined_description
}
]
}
]
}
logger.info(f"Appending new description to existing ticket {existing_ticket_id}")
else:
error_msg = f"Failed to get existing ticket details: {existing_ticket_response.text}"
logger.warning(error_msg)
return json.dumps({
"success": False,
"message": error_msg
})
# Update existing ticket
url = jira_rest_url(str(token.cloud_id), f"issue/{existing_ticket_id}")
response = requests.put(url, headers=headers, json=api_data)
if response.status_code not in (200, 204):
return json.dumps({
"success": False,
"message": f"Failed to update Jira ticket: {response.text}"
})
# Get updated ticket details
get_response = requests.get(url, headers=headers)
if get_response.status_code != 200:
logger.warning(f"Failed to get updated ticket details: {get_response.text}")
issue_result = {"key": existing_ticket_id}
else:
issue_result = get_response.json()
action_message = "updated"
else:
# Create new ticket
url = jira_rest_url(str(token.cloud_id), "issue")
response = requests.post(url, headers=headers, json=api_data)
if response.status_code not in (200, 201):
return json.dumps({
"success": False,
"message": f"Failed to create Jira ticket: {response.text}"
})
issue_result = response.json()
action_message = "created"
# Get the site URL from the token
site_url = token.site_url
if not site_url:
# Try to get the domain from the token
domain = token.domain
if domain:
site_url = f"https://{domain}.atlassian.net"
else:
# Try to get the site URL from the cloud resources
try:
resources_url = jira_accessible_resources_url()
resources_response = requests.get(resources_url, headers={
"Authorization": f"Bearer {token.access_token}",
"Accept": "application/json"
})
if resources_response.status_code == 200:
resources = resources_response.json()
for resource in resources:
if resource.get("id") == str(token.cloud_id):
site_url = resource.get("url", "")
break
except Exception as e:
logger.warning(f"Failed to get site URL from resources: {e}")
# Construct the ticket URL
ticket_key = issue_result["key"]
if site_url:
# Remove trailing slash if present
if site_url.endswith("/"):
site_url = site_url[:-1]
# Construct the URL using the site URL
ticket_url = f"{site_url}/browse/{ticket_key}"
else:
# Get the organization name for the URL
org_name = organization.name.lower().replace(" ", "-") if organization.name else "organization"
# Use the Atlassian Cloud URL format
ticket_url = f"https://{org_name}.atlassian.net/browse/{ticket_key}"
# Update the session with ticket information
session_repo = SessionToAgentRepository(db)
try:
# Use the combined description for updates, or just the new description for new tickets
final_description = combined_description if is_update and 'combined_description' in locals() else description
session_repo.update_session(
str(self.session_id), # Ensure session_id is a string
{
"ticket_id": issue_result["key"],
"ticket_status": "Updated" if is_update else "Created",
"ticket_summary": summary,
"ticket_description": final_description,
"integration_type": "JIRA",
"ticket_priority": priority if priority_available else None,
"ticket_url": ticket_url
}
)
except Exception as e:
logger.error(f"Failed to update session with ticket information: {e}")
# Continue since the ticket was created/updated successfully
return json.dumps({
"success": True,
"message": f"Ticket {action_message} successfully: {issue_result['key']}",
"ticket_id": issue_result["key"],
"ticket_url": ticket_url,
"was_updated": is_update
})
except requests.exceptions.RequestException as e:
logger.error(f"Request error {'updating' if is_update else 'creating'} Jira ticket: {e}")
return json.dumps({
"success": False,
"message": f"Request error {'updating' if is_update else 'creating'} Jira ticket: {str(e)}"
})
except Exception as e:
logger.error(f"Error {'updating' if 'is_update' in locals() and is_update else 'creating'} Jira ticket: {str(e)}")
return json.dumps({
"success": False,
"message": f"Error {'updating' if 'is_update' in locals() and is_update else 'creating'} Jira ticket: {str(e)}"
})
def get_ticket_status(self, ticket_id: Optional[str] = None) -> str:
"""
Get the status of a Jira ticket.
Args:
ticket_id (str, optional): The ID of the ticket to check. If not provided, will check for a ticket associated with the current session.
Returns:
str: JSON string with information about the ticket including its status.
"""
try:
import json
import requests
from datetime import datetime, timedelta
# Use context manager for database operations
with SessionLocal() as db:
# If no ticket ID is provided, check if there's a ticket for this session
if not ticket_id:
session_repo = SessionToAgentRepository(db)
try:
session = session_repo.get_session(str(self.session_id))
except Exception as e:
logger.error(f"Failed to get session: {e}")
return json.dumps({
"success": False,
"message": f"Failed to get session: {str(e)}"
})
if not session or not session.ticket_id:
return json.dumps({
"success": False,
"message": "No ticket found for this session"
})
ticket_id = session.ticket_id
# Get the organization
try:
org_uuid = UUID(str(self.org_id))
organization = db.query(Organization).filter(
Organization.id == org_uuid
).first()
except (ValueError, TypeError) as e:
logger.error(f"Invalid organization ID format: {e}")
return json.dumps({
"success": False,
"message": f"Invalid organization ID format: {str(e)}"
})
if not organization:
return json.dumps({
"success": False,
"message": "Organization not found"
})
# Get the ticket status from Jira
from app.models.jira import JiraToken
token = db.query(JiraToken).filter(
JiraToken.organization_id == organization.id
).first()
if not token:
return json.dumps({
"success": False,
"message": "No Jira connection found"
})
# Check if token is valid and refresh if needed
is_valid = self.jira_service.validate_token(token)
if not is_valid:
# Implement synchronous token refresh
try:
client_id = settings.JIRA_CLIENT_ID
client_secret = settings.JIRA_CLIENT_SECRET
if not client_id or not client_secret:
return json.dumps({
"success": False,
"message": "Jira client credentials not configured"
})
# Refresh token using requests
refresh_data = {
"grant_type": "refresh_token",
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": token.refresh_token
}
refresh_response = requests.post(
jira_token_url(),
data=refresh_data
)
if refresh_response.status_code != 200:
logger.error(f"Failed to refresh token: {refresh_response.text}")
return json.dumps({
"success": False,
"message": f"Failed to refresh Jira token: {refresh_response.text}"
})
# Parse response and update token
token_data = refresh_response.json()
# Update token in database
token.access_token = token_data["access_token"]
token.refresh_token = token_data.get("refresh_token", token.refresh_token)
token.token_type = token_data["token_type"]
token.expires_at = datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
db.commit()
logger.info("Successfully refreshed Jira token")
except Exception as e:
logger.error(f"Error refreshing Jira token: {e}")
return json.dumps({
"success": False,
"message": f"Error refreshing Jira token: {str(e)}"
})
# Get the issue
headers = {
"Authorization": f"Bearer {token.access_token}",
"Accept": "application/json"
}
# Ensure ticket_id is a string before using it in the URL
ticket_id_str = str(ticket_id) if ticket_id else ""
url = jira_rest_url(str(token.cloud_id), f"issue/{ticket_id_str}")
try:
response = requests.get(url, headers=headers)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to make request to Jira API: {e}")
return json.dumps({
"success": False,
"message": f"Failed to make request to Jira API: {str(e)}"
})
if response.status_code != 200:
return json.dumps({
"success": False,
"message": f"Failed to get Jira ticket: {response.text}"
})
try:
issue_data = response.json()
except ValueError as e:
logger.error(f"Failed to parse Jira response: {e}")
return json.dumps({
"success": False,
"message": f"Failed to parse Jira response: {str(e)}"
})
# Get the site URL from the token
site_url = token.site_url
if not site_url:
# Try to get the domain from the token
domain = token.domain
if domain:
site_url = f"https://{domain}.atlassian.net"
else:
# Try to get the site URL from the cloud resources
try:
resources_url = jira_accessible_resources_url()
resources_response = requests.get(resources_url, headers={
"Authorization": f"Bearer {token.access_token}",
"Accept": "application/json"
})
if resources_response.status_code == 200:
resources = resources_response.json()
for resource in resources:
if resource.get("id") == str(token.cloud_id):
site_url = resource.get("url", "")
break
except Exception as e:
logger.warning(f"Failed to get site URL from resources: {e}")
# Construct the ticket URL
if site_url:
# Remove trailing slash if present
if site_url.endswith("/"):
site_url = site_url[:-1]
# Construct the URL using the site URL
ticket_url = f"{site_url}/browse/{ticket_id_str}"
else:
# Get the organization name for the URL
org_name = organization.name.lower().replace(" ", "-") if organization.name else "organization"
# Use the Atlassian Cloud URL format
ticket_url = f"https://{org_name}.atlassian.net/browse/{ticket_id_str}"
return json.dumps({
"success": True,
"ticket_id": ticket_id_str,
"ticket_status": issue_data.get("fields", {}).get("status", {}).get("name", "Unknown"),
"ticket_summary": issue_data.get("fields", {}).get("summary", ""),
"ticket_description": issue_data.get("fields", {}).get("description", ""),
"ticket_priority": issue_data.get("fields", {}).get("priority", {}).get("name", ""),
"ticket_url": ticket_url
})
except Exception as e:
logger.error(f"Error getting Jira ticket status: {str(e)}")
return json.dumps({
"success": False,
"message": f"Error getting Jira ticket status: {str(e)}"
})
def check_existing_ticket(self) -> str:
"""
Check if a Jira ticket already exists for the current session.
Returns:
str: JSON string with information about whether a ticket exists.
"""
try:
import json
# Use context manager for database operations
with SessionLocal() as db:
# Get the session
session_repo = SessionToAgentRepository(db)
try:
session = session_repo.get_session(str(self.session_id))
except Exception as e:
logger.error(f"Failed to get session: {e}")
return json.dumps({
"exists": False,
"message": f"Failed to get session: {str(e)}"
})
if not session or not session.ticket_id:
return json.dumps({
"exists": False,
"message": "No ticket found for this session"
})
# Get the ticket status
ticket_status_str = self.get_ticket_status(session.ticket_id)
ticket_status = json.loads(ticket_status_str)
if not ticket_status.get("success", False):
return json.dumps({
"exists": True,
"ticket_id": session.ticket_id,
"ticket_status": session.ticket_status,
"ticket_summary": session.ticket_summary,
"ticket_description": session.ticket_description,
"ticket_priority": session.ticket_priority,
"message": "Ticket exists in the database but could not be retrieved from Jira"
})
return json.dumps({
"exists": True,
"ticket_id": ticket_status.get("ticket_id"),
"ticket_status": ticket_status.get("ticket_status"),
"ticket_summary": ticket_status.get("ticket_summary"),
"ticket_description": ticket_status.get("ticket_description"),
"ticket_priority": ticket_status.get("ticket_priority"),
"ticket_url": ticket_status.get("ticket_url"),
"message": "Ticket exists"
})
except Exception as e:
logger.error(f"Error checking existing ticket: {str(e)}")
return json.dumps({
"exists": False,
"message": f"Error checking existing ticket: {str(e)}"
})
# Private async methods that will be called by the public synchronous methods
# Note: We've removed the async _check_existing_ticket method and replaced it with a synchronous implementation