Adds `Jira.GetAvailableAtlassianClouds` tool, which provides a list of clouds available (checking which Clouds were actually authorized by the current auth token). Refactors the interface of every tool to accept an `atlassian_cloud_id` argument (when not provided, try to get a unique cloud ID - if multiple are available, raises a Retryable error with the list of Clouds available instructing to select one). Gets rid of all caching. Now storing the global semaphore to the context object. The global semaphore is important because some tools depend on others, and each tool instantiates its own Jira HTTP client. Storing the semaphore in the context object ensures that all HTTP clients will respect the concurrency limit. Removes from tool responses the Atlassian URLs linking to objects in the Jira GUI (users, projects, issues, etc. We do not keep track of the cloud name anymore, which is required to build the objects' URLs. Extends/refactors unit tests accordingly. Evals checking LLM behavior when: - a cloud ID is explicitly mentioned in the prompt; - no cloud ID is mentioned; - a "multiple clouds available" error is raised and the user is prompted to pick one; - user request triggers another tool call after having previously picked a cloud ID (in the same chat context);
1350 lines
40 KiB
Python
1350 lines
40 KiB
Python
import asyncio
|
|
import base64
|
|
import json
|
|
import mimetypes
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from contextlib import suppress
|
|
from datetime import date, datetime
|
|
from typing import Any, cast
|
|
|
|
import httpx
|
|
from arcade_tdk import ToolContext
|
|
from arcade_tdk.errors import RetryableToolError, ToolExecutionError
|
|
|
|
from arcade_jira.constants import JIRA_BASE_URL, STOP_WORDS
|
|
from arcade_jira.exceptions import JiraToolExecutionError, MultipleItemsFoundError, NotFoundError
|
|
|
|
|
|
def remove_none_values(data: dict) -> dict:
|
|
"""Remove all keys with None values from the dictionary."""
|
|
return {k: v for k, v in data.items() if v is not None}
|
|
|
|
|
|
def safe_delete_dict_keys(data: dict, keys: list[str]) -> dict:
|
|
for key in keys:
|
|
with suppress(KeyError):
|
|
del data[key]
|
|
return data
|
|
|
|
|
|
def convert_date_string_to_date(date_string: str) -> date:
|
|
return datetime.strptime(date_string, "%Y-%m-%d").date()
|
|
|
|
|
|
def is_valid_date_string(date_string: str) -> bool:
|
|
try:
|
|
convert_date_string_to_date(date_string)
|
|
except ValueError:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def quote(v: str) -> str:
|
|
quoted = v.replace('"', r"\"")
|
|
return f'"{quoted}"'
|
|
|
|
|
|
def build_search_issues_jql(
|
|
keywords: str | None = None,
|
|
due_from: date | None = None,
|
|
due_until: date | None = None,
|
|
status: str | None = None,
|
|
priority: str | None = None,
|
|
assignee: str | None = None,
|
|
project: str | None = None,
|
|
issue_type: str | None = None,
|
|
labels: list[str] | None = None,
|
|
parent_issue: str | None = None,
|
|
) -> str:
|
|
clauses: list[str] = []
|
|
|
|
if keywords:
|
|
kw_clauses = [
|
|
f"text ~ {quote(k.casefold())}"
|
|
for k in keywords.split()
|
|
if k.casefold() not in STOP_WORDS
|
|
]
|
|
clauses.append("(" + " AND ".join(kw_clauses) + ")")
|
|
|
|
if due_from:
|
|
clauses.append(f'dueDate >= "{due_from.isoformat()}"')
|
|
if due_until:
|
|
clauses.append(f'dueDate <= "{due_until.isoformat()}"')
|
|
|
|
if labels:
|
|
label_list = ",".join(quote(label) for label in labels)
|
|
clauses.append(f"labels IN ({label_list})")
|
|
|
|
standard_cases = [
|
|
("status", status),
|
|
("priority", priority),
|
|
("assignee", assignee),
|
|
("project", project),
|
|
("issuetype", issue_type),
|
|
("parent", parent_issue),
|
|
]
|
|
|
|
for field, value in standard_cases:
|
|
if value:
|
|
clauses.append(f"{field} = {quote(value)}")
|
|
|
|
return " AND ".join(clauses) if clauses else ""
|
|
|
|
|
|
def clean_issue_dict(issue: dict) -> dict:
|
|
fields = cast(dict, issue["fields"])
|
|
rendered_fields = issue.get("renderedFields", {})
|
|
|
|
fields["id"] = issue["id"]
|
|
fields["key"] = issue["key"]
|
|
fields["title"] = fields["summary"]
|
|
|
|
if fields.get("parent"):
|
|
fields["parent"] = get_summarized_issue_dict(fields["parent"])
|
|
|
|
if fields["assignee"]:
|
|
fields["assignee"] = clean_user_dict(fields["assignee"])
|
|
|
|
if fields["creator"]:
|
|
fields["creator"] = clean_user_dict(fields["creator"])
|
|
|
|
if fields["reporter"]:
|
|
fields["reporter"] = clean_user_dict(fields["reporter"])
|
|
|
|
if fields.get("description"):
|
|
fields["description"] = rendered_fields.get("description")
|
|
|
|
if fields.get("environment"):
|
|
fields["environment"] = rendered_fields.get("environment")
|
|
|
|
if fields.get("worklog"):
|
|
fields["worklog"] = {
|
|
"items": rendered_fields.get("worklog", {}).get("worklogs", []),
|
|
"total": len(rendered_fields.get("worklog", {}).get("worklogs", [])),
|
|
}
|
|
|
|
if fields.get("attachment"):
|
|
fields["attachments"] = [
|
|
clean_attachment_dict(attachment) for attachment in fields.get("attachment", [])
|
|
]
|
|
|
|
add_identified_fields_to_issue(fields, ["status", "issuetype", "priority", "project"])
|
|
|
|
safe_delete_dict_keys(
|
|
fields,
|
|
[
|
|
"subtasks",
|
|
"summary",
|
|
"assignee",
|
|
"creator",
|
|
"issuetype",
|
|
"lastViewed",
|
|
"updated",
|
|
"statusCategory",
|
|
"statuscategorychangedate",
|
|
"votes",
|
|
"watches",
|
|
"attachment",
|
|
"comment",
|
|
"self",
|
|
],
|
|
)
|
|
|
|
return fields
|
|
|
|
|
|
def add_identified_fields_to_issue(
|
|
fields_dict: dict[str, Any],
|
|
field_names: list[str],
|
|
) -> dict[str, Any]:
|
|
for field_name in field_names:
|
|
if fields_dict.get(field_name):
|
|
data = {
|
|
"name": fields_dict[field_name]["name"],
|
|
"id": fields_dict[field_name]["id"],
|
|
}
|
|
if "key" in fields_dict[field_name]:
|
|
data["key"] = fields_dict[field_name]["key"]
|
|
fields_dict[field_name] = data
|
|
|
|
return fields_dict
|
|
|
|
|
|
def clean_comment_dict(comment: dict, include_adf_content: bool = False) -> dict:
|
|
data = {
|
|
"id": comment["id"],
|
|
"author": {
|
|
"name": comment["author"]["displayName"],
|
|
"email": comment["author"]["emailAddress"],
|
|
},
|
|
"body": comment["renderedBody"],
|
|
"created_at": comment["created"],
|
|
}
|
|
|
|
if include_adf_content:
|
|
data["adf_body"] = comment["body"]
|
|
|
|
return data
|
|
|
|
|
|
def clean_project_dict(project: dict) -> dict:
|
|
data = {
|
|
"id": project["id"],
|
|
"key": project["key"],
|
|
"name": project["name"],
|
|
}
|
|
|
|
if "description" in project:
|
|
data["description"] = project["description"]
|
|
|
|
if "email" in project:
|
|
data["email"] = project["email"]
|
|
|
|
if "projectCategory" in project:
|
|
data["category"] = project["projectCategory"]
|
|
|
|
if "style" in project:
|
|
data["style"] = project["style"]
|
|
|
|
return data
|
|
|
|
|
|
def clean_issue_type_dict(issue_type: dict) -> dict:
|
|
data = {
|
|
"id": issue_type["id"],
|
|
"name": issue_type["name"],
|
|
"description": issue_type["description"],
|
|
}
|
|
|
|
if "scope" in issue_type:
|
|
data["scope"] = issue_type["scope"]
|
|
|
|
return data
|
|
|
|
|
|
def clean_user_dict(user: dict) -> dict:
|
|
data = {
|
|
"id": user["accountId"],
|
|
"name": user["displayName"],
|
|
"active": user["active"],
|
|
}
|
|
|
|
if user.get("emailAddress"):
|
|
data["email"] = user["emailAddress"]
|
|
|
|
if user.get("accountType"):
|
|
data["account_type"] = user["accountType"]
|
|
|
|
if user.get("timeZone"):
|
|
data["timezone"] = user["timeZone"]
|
|
|
|
if user.get("active"):
|
|
data["active"] = user["active"]
|
|
|
|
return data
|
|
|
|
|
|
def clean_attachment_dict(attachment: dict) -> dict:
|
|
return {
|
|
"id": attachment["id"],
|
|
"filename": attachment["filename"],
|
|
"mime_type": attachment["mimeType"],
|
|
"size": {"bytes": attachment["size"]},
|
|
"author": clean_user_dict(attachment["author"]),
|
|
}
|
|
|
|
|
|
def clean_priority_scheme_dict(scheme: dict) -> dict:
|
|
data = {
|
|
"id": scheme["id"],
|
|
"name": scheme["name"],
|
|
"description": scheme["description"],
|
|
"is_default": scheme["isDefault"],
|
|
}
|
|
|
|
if isinstance(scheme.get("priorities"), dict):
|
|
all_priorities = scheme["priorities"].get("isLast", True)
|
|
|
|
data["priorities"] = [
|
|
clean_priority_dict(priority) for priority in scheme["priorities"]["values"]
|
|
]
|
|
|
|
if not all_priorities:
|
|
# Avoid circular import
|
|
from arcade_jira.tools.priorities import (
|
|
list_priorities_associated_with_a_priority_scheme,
|
|
)
|
|
|
|
data["priorities"]["message"] = (
|
|
"Not all priorities are listed. Paginate the "
|
|
f"`Jira.{list_priorities_associated_with_a_priority_scheme.__tool_name__}` tool "
|
|
"to get the full list of priorities in this priority scheme."
|
|
)
|
|
|
|
if isinstance(scheme.get("projects"), dict):
|
|
all_projects = scheme["projects"].get("isLast", True)
|
|
data["projects"] = [clean_project_dict(project) for project in scheme["projects"]["values"]]
|
|
if not all_projects:
|
|
# Avoid circular import
|
|
from arcade_jira.tools.priorities import list_projects_associated_with_a_priority_scheme
|
|
|
|
data["projects"]["message"] = (
|
|
"Not all projects are listed. Paginate the "
|
|
f"`Jira.{list_projects_associated_with_a_priority_scheme.__tool_name__}` tool "
|
|
"to get the full list of projects in this priority scheme."
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
def clean_priority_dict(priority: dict) -> dict:
|
|
data = {
|
|
"id": priority["id"],
|
|
"name": priority["name"],
|
|
"description": priority["description"],
|
|
}
|
|
|
|
if "statusColor" in priority:
|
|
data["statusColor"] = priority["statusColor"]
|
|
|
|
return data
|
|
|
|
|
|
def clean_labels(labels: list[str] | None) -> list[str] | None:
|
|
if not labels:
|
|
return None
|
|
return [label.strip().replace(" ", "_") for label in labels]
|
|
|
|
|
|
def get_summarized_issue_dict(issue: dict) -> dict:
|
|
fields = issue["fields"]
|
|
return {
|
|
"id": issue["id"],
|
|
"key": issue["key"],
|
|
"title": fields.get("summary"),
|
|
"status": fields.get("status", {}).get("name"),
|
|
"type": fields.get("issuetype", {}).get("name"),
|
|
"priority": fields.get("priority", {}).get("name"),
|
|
}
|
|
|
|
|
|
def add_pagination_to_response(
|
|
response: dict[str, Any],
|
|
items: list[dict[str, Any]],
|
|
limit: int,
|
|
offset: int,
|
|
max_results: int | None = None,
|
|
) -> dict[str, Any]:
|
|
next_offset = offset + limit
|
|
if max_results:
|
|
next_offset = min(next_offset, max_results - limit)
|
|
|
|
response["pagination"] = {
|
|
"limit": limit,
|
|
"total_results": len(items),
|
|
}
|
|
|
|
if response.get("isLast") is True:
|
|
response["pagination"]["is_last_page"] = True
|
|
elif response.get("isLast") is False or (len(items) >= limit and next_offset > offset):
|
|
response["pagination"]["next_offset"] = next_offset
|
|
else:
|
|
response["pagination"]["is_last_page"] = True
|
|
|
|
with suppress(KeyError):
|
|
del response["isLast"]
|
|
|
|
return response
|
|
|
|
|
|
def simplify_user_dict(user: dict) -> dict:
|
|
return {
|
|
"id": user["id"],
|
|
"name": user["name"],
|
|
"email": user["email"],
|
|
}
|
|
|
|
|
|
async def find_multiple_unique_users(
|
|
context: ToolContext,
|
|
user_identifiers: list[str],
|
|
exact_match: bool = False,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Find users matching either their display name, email address, or account ID.
|
|
|
|
By default, the search will match prefixes. A user_identifier of "john" will match
|
|
"John Doe", "Johnson", "john.doe@example.com", etc.
|
|
|
|
If `enforce_exact_match` is set to True, the search will only return users that have either
|
|
a display name, email address, or account ID that match the exact user_identifier.
|
|
"""
|
|
from arcade_jira.tools.users import ( # Avoid circular import
|
|
get_user_by_id,
|
|
get_users_without_id,
|
|
)
|
|
|
|
users: list[dict[str, Any]] = []
|
|
|
|
responses = await asyncio.gather(*[
|
|
get_users_without_id(
|
|
context=context,
|
|
name_or_email=user_identifier,
|
|
enforce_exact_match=exact_match,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
for user_identifier in user_identifiers
|
|
])
|
|
|
|
search_by_id: list[str] = []
|
|
|
|
for response in responses:
|
|
user_identifier = response["query"]["name_or_email"]
|
|
|
|
if response["pagination"]["total_results"] > 1:
|
|
simplified_users = [simplify_user_dict(user) for user in response["users"]]
|
|
raise MultipleItemsFoundError(
|
|
message=f"Multiple users found with name or email '{user_identifier}'. "
|
|
f"Please provide a unique ID: {json.dumps(simplified_users)}"
|
|
)
|
|
|
|
elif response["pagination"]["total_results"] == 0:
|
|
search_by_id.append(user_identifier)
|
|
|
|
else:
|
|
users.append(response["users"][0])
|
|
|
|
if search_by_id:
|
|
responses = await asyncio.gather(*[
|
|
get_user_by_id(
|
|
context=context,
|
|
user_id=user_id,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
for user_id in search_by_id
|
|
])
|
|
for response in responses:
|
|
if response["user"]:
|
|
users.append(response["user"])
|
|
else:
|
|
raise NotFoundError(
|
|
message=f"No user found with '{response['query']['user_id']}'.",
|
|
)
|
|
|
|
return users
|
|
|
|
|
|
async def find_unique_project(
|
|
context: ToolContext,
|
|
project_identifier: str,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Find a unique project by its ID, key, or name
|
|
|
|
Args:
|
|
project_identifier: The ID, key, or name of the project to find.
|
|
|
|
Returns:
|
|
The project found.
|
|
"""
|
|
# Avoid circular import
|
|
from arcade_jira.tools.projects import get_project_by_id, search_projects
|
|
|
|
# Try to find project by ID or key
|
|
response = await get_project_by_id(
|
|
context=context,
|
|
project=project_identifier,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if response.get("project"):
|
|
return cast(dict, response["project"])
|
|
|
|
# If not found, search by name
|
|
response = await search_projects(
|
|
context=context,
|
|
keywords=project_identifier,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
projects = response["projects"]
|
|
if len(projects) == 1:
|
|
return cast(dict, projects[0])
|
|
elif len(projects) > 1:
|
|
simplified_projects = [
|
|
{
|
|
"id": project["id"],
|
|
"name": project["name"],
|
|
}
|
|
for project in projects
|
|
]
|
|
raise MultipleItemsFoundError(
|
|
message=f"Multiple projects found with name/key/ID '{project_identifier}'. "
|
|
f"Please provide a unique ID: {json.dumps(simplified_projects)}"
|
|
)
|
|
|
|
raise NotFoundError(message=f"Project not found with name/key/ID '{project_identifier}'")
|
|
|
|
|
|
async def find_unique_priority(
|
|
context: ToolContext,
|
|
priority_identifier: str,
|
|
project_id: str,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Find a unique priority by ID or name that is associated with a project
|
|
|
|
Args:
|
|
priority_identifier: The ID or name of the priority to find.
|
|
project_id: The ID of the project to find the priority for.
|
|
|
|
Returns:
|
|
The priority found.
|
|
"""
|
|
# Avoid circular import
|
|
from arcade_jira.tools.priorities import (
|
|
get_priority_by_id,
|
|
list_priorities_available_to_a_project,
|
|
)
|
|
|
|
# Try to get the priority by ID first
|
|
response = await get_priority_by_id(
|
|
context=context,
|
|
priority_id=priority_identifier,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if response.get("priority"):
|
|
return cast(dict, response["priority"])
|
|
|
|
# If not found, search by name
|
|
response = await list_priorities_available_to_a_project(
|
|
context=context,
|
|
project=project_id,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
|
|
if response.get("error"):
|
|
raise JiraToolExecutionError(response["error"])
|
|
|
|
priorities = response["priorities_available"]
|
|
matches: list[dict[str, Any]] = []
|
|
|
|
for priority in priorities:
|
|
if priority["name"].casefold() == priority_identifier.casefold():
|
|
matches.append(priority)
|
|
|
|
if len(matches) == 1:
|
|
return cast(dict, matches[0])
|
|
elif len(matches) > 1:
|
|
simplified_matches = [
|
|
{
|
|
"id": match["id"],
|
|
"name": match["name"],
|
|
}
|
|
for match in matches
|
|
]
|
|
raise MultipleItemsFoundError(
|
|
message=f"Multiple priorities found with name '{priority_identifier}'. "
|
|
f"Please provide a unique ID: {json.dumps(simplified_matches)}"
|
|
)
|
|
|
|
raise NotFoundError(message=f"Priority not found with ID or name '{priority_identifier}'")
|
|
|
|
|
|
async def find_unique_issue_type(
|
|
context: ToolContext,
|
|
issue_type_identifier: str,
|
|
project_id: str,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Find a unique issue type by its ID or name that is associated with a project
|
|
|
|
Args:
|
|
issue_type_identifier: The ID or name of the issue type to find.
|
|
project_id: The ID of the project to find the issue type for.
|
|
|
|
Returns:
|
|
The issue type found.
|
|
"""
|
|
# Avoid circular import
|
|
from arcade_jira.tools.issues import get_issue_type_by_id, list_issue_types_by_project
|
|
|
|
# Try to get the issue type by ID first
|
|
response = await get_issue_type_by_id(
|
|
context=context,
|
|
issue_type_id=issue_type_identifier,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if response.get("issue_type"):
|
|
return cast(dict, response["issue_type"])
|
|
|
|
# If not found, search by name
|
|
response = await list_issue_types_by_project(
|
|
context=context,
|
|
project=project_id,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
|
|
if response.get("error"):
|
|
raise JiraToolExecutionError(response["error"])
|
|
|
|
issue_types = response["issue_types"]
|
|
matches: list[dict[str, Any]] = []
|
|
|
|
for issue_type in issue_types:
|
|
if issue_type["name"].casefold() == issue_type_identifier.casefold():
|
|
matches.append(issue_type)
|
|
|
|
if len(matches) == 1:
|
|
return cast(dict, matches[0])
|
|
elif len(matches) > 1:
|
|
simplified_matches = [
|
|
{
|
|
"id": match["id"],
|
|
"name": match["name"],
|
|
}
|
|
for match in matches
|
|
]
|
|
raise MultipleItemsFoundError(
|
|
message=f"Multiple issue types found with name '{issue_type_identifier}'. "
|
|
f"Please provide a unique ID: {json.dumps(simplified_matches)}"
|
|
)
|
|
|
|
available_issue_types = json.dumps([
|
|
{
|
|
"id": issue_type["id"],
|
|
"name": issue_type["name"],
|
|
}
|
|
for issue_type in issue_types
|
|
])
|
|
|
|
raise NotFoundError(
|
|
message=f"Issue type not found with ID or name '{issue_type_identifier}'. "
|
|
f"These are the issue types available for the project: {available_issue_types}"
|
|
)
|
|
|
|
|
|
async def find_unique_user(
|
|
context: ToolContext,
|
|
user_identifier: str,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Find a unique user by their ID, key, email address, or display name."""
|
|
# Avoid circular import
|
|
from arcade_jira.tools.users import get_user_by_id, get_users_without_id
|
|
|
|
# Try to get the user by ID
|
|
response = await get_user_by_id(
|
|
context=context,
|
|
user_id=user_identifier,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if response.get("user"):
|
|
return cast(dict, response["user"])
|
|
|
|
# Search for the user name or email, if not found by ID
|
|
response = await get_users_without_id(
|
|
context=context,
|
|
name_or_email=user_identifier,
|
|
enforce_exact_match=True,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
users = response["users"]
|
|
|
|
if len(users) == 1:
|
|
return cast(dict, users[0])
|
|
elif len(users) > 1:
|
|
simplified_users = [
|
|
{
|
|
"id": user["id"],
|
|
"name": user["name"],
|
|
"email": user["email"],
|
|
}
|
|
for user in users
|
|
]
|
|
raise MultipleItemsFoundError(
|
|
message=f"Multiple users found with name or email '{user_identifier}'. "
|
|
f"Please provide a unique ID: {json.dumps(simplified_users)}"
|
|
)
|
|
|
|
raise NotFoundError(message=f"User not found with ID, name or email '{user_identifier}'")
|
|
|
|
|
|
async def get_single_project(
|
|
context: ToolContext,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
from arcade_jira.tools.projects import list_projects
|
|
|
|
projects = await paginate_all_items(
|
|
context=context,
|
|
tool=list_projects,
|
|
response_items_key="projects",
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
|
|
if len(projects) == 0:
|
|
raise NotFoundError(message="No projects found in this account.")
|
|
|
|
if len(projects) == 1:
|
|
return cast(dict[str, Any], projects[0])
|
|
|
|
available_projects_str = json.dumps([
|
|
{
|
|
"id": project["id"],
|
|
"name": project["name"],
|
|
}
|
|
for project in projects
|
|
])
|
|
|
|
raise MultipleItemsFoundError(message=f"Multiple projects found: {available_projects_str}")
|
|
|
|
|
|
def build_file_data(
|
|
filename: str,
|
|
file_content_str: str | None,
|
|
file_content_base64: str | None,
|
|
file_type: str | None = None,
|
|
file_encoding: str = "utf-8",
|
|
) -> dict[str, tuple]:
|
|
if file_content_str is not None:
|
|
try:
|
|
file_content = file_content_str.encode(file_encoding)
|
|
except LookupError as exc:
|
|
raise ToolExecutionError(message=f"Unknown encoding: {file_encoding}") from exc
|
|
except Exception as exc:
|
|
raise ToolExecutionError(
|
|
message=f"Failed to encode file content string with {file_encoding} "
|
|
f"encoding: {exc!s}"
|
|
) from exc
|
|
elif file_content_base64 is not None:
|
|
try:
|
|
file_content = base64.b64decode(file_content_base64)
|
|
except Exception as exc:
|
|
raise ToolExecutionError(
|
|
message=f"Failed to decode base64 file content: {exc!s}"
|
|
) from exc
|
|
|
|
if not file_type:
|
|
# guess_type returns None if the file type is not recognized
|
|
file_type = mimetypes.guess_type(filename)[0]
|
|
|
|
if file_type:
|
|
return {"file": (filename, file_content, file_type)}
|
|
|
|
return {"file": (filename, file_content)}
|
|
|
|
|
|
def build_adf_doc(text: str) -> dict:
|
|
return {
|
|
"type": "doc",
|
|
"version": 1,
|
|
"content": [
|
|
{
|
|
"type": "paragraph",
|
|
"content": [{"type": "text", "text": text}],
|
|
}
|
|
for text in text.split("\n")
|
|
],
|
|
}
|
|
|
|
|
|
async def paginate_all_items(
|
|
context: ToolContext,
|
|
tool: Callable,
|
|
response_items_key: str,
|
|
limit: int | None = None,
|
|
offset: int | None = None,
|
|
**kwargs: Any,
|
|
) -> list[Any]:
|
|
"""Paginate all items from a tool."""
|
|
keep_paginating = True
|
|
items: list[Any] = []
|
|
|
|
if limit is not None:
|
|
kwargs["limit"] = limit
|
|
|
|
if offset is not None:
|
|
kwargs["offset"] = offset
|
|
|
|
while keep_paginating:
|
|
response = await tool(context, **kwargs)
|
|
|
|
if response.get("error"):
|
|
raise JiraToolExecutionError(response["error"])
|
|
|
|
next_offset = response["pagination"].get("next_offset")
|
|
kwargs["offset"] = next_offset
|
|
keep_paginating = isinstance(next_offset, int)
|
|
items.extend(response[response_items_key])
|
|
|
|
return items
|
|
|
|
|
|
async def paginate_all_priority_schemes(
|
|
context: ToolContext,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> list[dict]:
|
|
"""Get all priority schemes."""
|
|
# Avoid circular import
|
|
from arcade_jira.tools.priorities import list_priority_schemes
|
|
|
|
return await paginate_all_items(
|
|
context=context,
|
|
tool=list_priority_schemes,
|
|
response_items_key="priority_schemes",
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
|
|
|
|
async def paginate_all_priorities_by_priority_scheme(
|
|
context: ToolContext,
|
|
scheme_id: str,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> list[dict]:
|
|
"""Get all priorities associated with a priority scheme."""
|
|
# Avoid circular import
|
|
from arcade_jira.tools.priorities import list_priorities_associated_with_a_priority_scheme
|
|
|
|
return await paginate_all_items(
|
|
context,
|
|
list_priorities_associated_with_a_priority_scheme,
|
|
"priorities",
|
|
scheme_id=scheme_id,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
|
|
|
|
async def validate_issue_args(
|
|
context: ToolContext,
|
|
due_date: str | None,
|
|
project: str | None,
|
|
issue_type: str | None,
|
|
priority: str | None,
|
|
parent_issue: str | None,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> tuple[dict | None, dict | None, str | dict | None, str | dict | None, dict | None]:
|
|
if due_date and not is_valid_date_string(due_date):
|
|
return (
|
|
{"error": f"Invalid `due_date` format: '{due_date}'. Please use YYYY-MM-DD."},
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
if not project and not parent_issue:
|
|
return (
|
|
{"error": "Must provide either `project` or `parent_issue` argument."},
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
error: dict[str, Any] | None = None
|
|
project_data = await get_project_by_project_identifier_or_by_parent_issue(
|
|
context=context,
|
|
project=project,
|
|
parent_issue_id=parent_issue,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
issue_type_data: str | dict[str, Any] | None = None
|
|
priority_data: str | dict[str, Any] | None = None
|
|
parent_issue_data: dict[str, Any] | None = None
|
|
|
|
if project_data.get("error"):
|
|
error = project_data
|
|
return error, None, issue_type_data, priority_data, parent_issue_data
|
|
|
|
error, issue_type_data = await resolve_issue_type(
|
|
context=context,
|
|
issue_type=issue_type,
|
|
project_data=project_data,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if error:
|
|
return error, project_data, issue_type_data, priority_data, parent_issue_data
|
|
|
|
error, priority_data = await resolve_issue_priority(
|
|
context=context,
|
|
priority=priority,
|
|
project_data=project_data,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if error:
|
|
return error, project_data, issue_type_data, priority_data, parent_issue_data
|
|
|
|
error, parent_issue_data = await resolve_parent_issue(
|
|
context=context,
|
|
parent_issue=parent_issue,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if error:
|
|
return error, project_data, issue_type_data, priority_data, parent_issue_data
|
|
|
|
return None, project_data, issue_type_data, priority_data, parent_issue_data
|
|
|
|
|
|
async def resolve_issue_type(
|
|
context: ToolContext,
|
|
issue_type: str | None,
|
|
project_data: dict,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> tuple[dict[str, Any] | None, str | dict[str, Any] | None]:
|
|
if issue_type == "":
|
|
return None, ""
|
|
elif issue_type:
|
|
try:
|
|
response = await find_unique_issue_type(
|
|
context=context,
|
|
issue_type_identifier=issue_type,
|
|
project_id=project_data["id"],
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
except JiraToolExecutionError as exc:
|
|
return {"error": exc.message}, None
|
|
else:
|
|
return None, response
|
|
|
|
return None, None
|
|
|
|
|
|
async def resolve_issue_priority(
|
|
context: ToolContext,
|
|
priority: str | None,
|
|
project_data: dict,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> tuple[dict[str, Any] | None, str | dict[str, Any] | None]:
|
|
if priority == "":
|
|
return None, ""
|
|
elif priority:
|
|
try:
|
|
priority_data = await find_unique_priority(
|
|
context=context,
|
|
priority_identifier=priority,
|
|
project_id=project_data["id"],
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
except JiraToolExecutionError as exc:
|
|
return {"error": exc.message}, None
|
|
else:
|
|
return None, priority_data
|
|
|
|
return None, None
|
|
|
|
|
|
async def resolve_parent_issue(
|
|
context: ToolContext,
|
|
parent_issue: str | None,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
if parent_issue == "":
|
|
return {"error": "Parent issue cannot be empty"}, None
|
|
elif parent_issue:
|
|
from arcade_jira.tools.issues import get_issue_by_id # Avoid circular import
|
|
|
|
try:
|
|
parent_issue_data = await get_issue_by_id(
|
|
context=context,
|
|
issue=parent_issue,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
except JiraToolExecutionError as exc:
|
|
return {"error": exc.message}, None
|
|
else:
|
|
return None, parent_issue_data["issue"]
|
|
|
|
return None, None
|
|
|
|
|
|
async def get_project_by_project_identifier_or_by_parent_issue(
|
|
context: ToolContext,
|
|
project: str | None,
|
|
parent_issue_id: str | None,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
from arcade_jira.tools.issues import get_issue_by_id # Avoid circular import
|
|
|
|
if not project and not parent_issue_id:
|
|
return {"error": "Must provide either `project` or `parent_issue_id` argument."}
|
|
|
|
if not project:
|
|
parent_issue_data = await get_issue_by_id(
|
|
context=context,
|
|
issue=parent_issue_id,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
if parent_issue_data.get("error"):
|
|
return {"error": f"Parent issue not found with ID {parent_issue_id}."}
|
|
project = cast(str, parent_issue_data["project"]["id"])
|
|
|
|
try:
|
|
project_data = await find_unique_project(
|
|
context=context,
|
|
project_identifier=project,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
except JiraToolExecutionError as exc:
|
|
return {"error": exc.message}
|
|
|
|
return project_data
|
|
|
|
|
|
async def resolve_issue_users(
|
|
context: ToolContext,
|
|
assignee: str | None,
|
|
reporter: str | None,
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> tuple[dict | None, str | dict | None, str | dict | None]:
|
|
assignee_data: str | dict | None = None
|
|
reporter_data: str | dict | None = None
|
|
|
|
if (not assignee and assignee != "") and (not reporter and reporter != ""):
|
|
return None, None, None
|
|
|
|
if assignee == "":
|
|
assignee_data = ""
|
|
elif assignee:
|
|
try:
|
|
assignee_data = await find_unique_user(
|
|
context=context,
|
|
user_identifier=assignee,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
except JiraToolExecutionError as exc:
|
|
return {"error": exc.message}, assignee_data, reporter_data
|
|
|
|
if reporter == "":
|
|
reporter_data = ""
|
|
elif reporter:
|
|
try:
|
|
reporter_data = await find_unique_user(
|
|
context=context,
|
|
user_identifier=reporter,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
except JiraToolExecutionError as exc:
|
|
return {"error": exc.message}, assignee_data, reporter_data
|
|
|
|
return None, assignee_data, reporter_data
|
|
|
|
|
|
async def find_priorities_by_project(
|
|
context: ToolContext,
|
|
project: dict[str, Any],
|
|
atlassian_cloud_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
# Avoid circular import
|
|
from arcade_jira.tools.priorities import list_projects_associated_with_a_priority_scheme
|
|
|
|
scheme_ids: set[str] = set()
|
|
priority_ids: set[str] = set()
|
|
priorities: list[dict[str, Any]] = []
|
|
|
|
priority_schemes = await paginate_all_priority_schemes(
|
|
context=context,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
|
|
if not priority_schemes:
|
|
raise NotFoundError("No priority schemes found") # noqa: TRY003
|
|
|
|
projects_by_scheme = await asyncio.gather(*[
|
|
list_projects_associated_with_a_priority_scheme(
|
|
context=context,
|
|
scheme_id=scheme["id"],
|
|
project=project["id"],
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
for scheme in priority_schemes
|
|
])
|
|
|
|
for scheme_index, scheme_projects in enumerate(projects_by_scheme):
|
|
if scheme_projects.get("error"):
|
|
return cast(dict, scheme_projects)
|
|
|
|
for scheme_project in scheme_projects["projects"]:
|
|
if scheme_project["id"] == project["id"]:
|
|
scheme = priority_schemes[scheme_index]
|
|
scheme_ids.add(scheme["id"])
|
|
break
|
|
|
|
if not scheme_ids:
|
|
return {"error": f"No priority schemes found for the project {project['id']}"}
|
|
|
|
priorities_by_scheme = await asyncio.gather(*[
|
|
paginate_all_priorities_by_priority_scheme(
|
|
context=context,
|
|
scheme_id=scheme_id,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
)
|
|
for scheme_id in scheme_ids
|
|
])
|
|
|
|
for priorities_available in priorities_by_scheme:
|
|
for priority in priorities_available:
|
|
if priority["id"] in priority_ids:
|
|
continue
|
|
priority_ids.add(priority["id"])
|
|
priorities.append(priority)
|
|
|
|
return {
|
|
"project": {
|
|
"id": project["id"],
|
|
"key": project["key"],
|
|
"name": project["name"],
|
|
},
|
|
"priorities_available": priorities,
|
|
}
|
|
|
|
|
|
def build_issue_update_request_body(
|
|
title: str | None,
|
|
description: str | None,
|
|
environment: str | None,
|
|
due_date: str | None,
|
|
parent_issue: dict | None,
|
|
issue_type: str | dict | None,
|
|
priority: str | dict | None,
|
|
assignee: str | dict | None,
|
|
reporter: str | dict | None,
|
|
labels: list[str] | None,
|
|
) -> dict[str, Any]:
|
|
body: dict[str, dict[str, Any]] = {"fields": {}, "update": {}}
|
|
|
|
build_issue_update_text_fields(body, title, description, environment)
|
|
build_issue_update_classifier_fields(body, issue_type, priority)
|
|
build_issue_update_user_fields(body, assignee, reporter)
|
|
build_issue_update_hierarchy_fields(body, parent_issue)
|
|
build_issue_update_date_fields(body, due_date)
|
|
|
|
if labels == []:
|
|
body["update"]["labels"] = [{"set": None}]
|
|
elif labels:
|
|
body["fields"]["labels"] = labels
|
|
|
|
return body
|
|
|
|
|
|
def build_issue_update_text_fields(
|
|
body: dict,
|
|
title: str | None,
|
|
description: str | None,
|
|
environment: str | None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
if title == "":
|
|
raise ValueError("Title cannot be empty") # noqa: TRY003
|
|
elif title:
|
|
body["fields"]["summary"] = title
|
|
|
|
if description == "":
|
|
body["update"]["description"] = [{"set": None}]
|
|
elif description:
|
|
body["fields"]["description"] = build_adf_doc(description)
|
|
|
|
if environment == "":
|
|
body["update"]["environment"] = [{"set": None}]
|
|
elif environment:
|
|
body["fields"]["environment"] = build_adf_doc(environment)
|
|
|
|
return body
|
|
|
|
|
|
def build_issue_update_user_fields(
|
|
body: dict,
|
|
assignee: str | dict | None,
|
|
reporter: str | dict | None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
if assignee == "":
|
|
body["update"]["assignee"] = [{"set": None}]
|
|
elif isinstance(assignee, dict):
|
|
body["fields"]["assignee"] = {"id": assignee["id"]}
|
|
elif assignee is not None:
|
|
raise ValueError(f"Invalid assignee: '{assignee}'") # noqa: TRY003
|
|
|
|
if reporter == "":
|
|
body["update"]["reporter"] = [{"set": None}]
|
|
elif isinstance(reporter, dict):
|
|
body["fields"]["reporter"] = {"id": reporter["id"]}
|
|
elif reporter is not None:
|
|
raise ValueError(f"Invalid reporter: '{reporter}'") # noqa: TRY003
|
|
|
|
return body
|
|
|
|
|
|
def build_issue_update_classifier_fields(
|
|
body: dict,
|
|
issue_type: str | dict | None,
|
|
priority: str | dict | None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
if issue_type == "":
|
|
raise ValueError("Issue type cannot be empty") # noqa: TRY003
|
|
elif isinstance(issue_type, dict):
|
|
body["fields"]["issuetype"] = {"id": issue_type["id"]}
|
|
elif issue_type is not None:
|
|
raise ValueError(f"Invalid issue type: '{issue_type}'") # noqa: TRY003
|
|
|
|
if priority == "":
|
|
raise ValueError("Priority cannot be empty") # noqa: TRY003
|
|
elif isinstance(priority, dict):
|
|
body["fields"]["priority"] = {"id": priority["id"]}
|
|
elif priority is not None:
|
|
raise ValueError(f"Invalid priority: '{priority}'") # noqa: TRY003
|
|
|
|
return body
|
|
|
|
|
|
def build_issue_update_hierarchy_fields(
|
|
body: dict,
|
|
parent_issue: dict | None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
if parent_issue:
|
|
body["fields"]["parent"] = {"id": parent_issue["id"]}
|
|
|
|
return body
|
|
|
|
|
|
def build_issue_update_date_fields(
|
|
body: dict,
|
|
due_date: str | None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
if due_date == "":
|
|
body["update"]["duedate"] = [{"set": None}]
|
|
elif due_date:
|
|
body["fields"]["duedate"] = due_date
|
|
|
|
return body
|
|
|
|
|
|
def extract_id(field: Any) -> dict[str, str] | None:
|
|
return {"id": field["id"]} if isinstance(field, dict) else None
|
|
|
|
|
|
async def resolve_cloud_id(context: ToolContext, cloud_id: str | None) -> str:
|
|
try:
|
|
uuid.UUID(cloud_id)
|
|
except (AttributeError, TypeError, ValueError):
|
|
is_valid_uuid = False
|
|
else:
|
|
is_valid_uuid = True
|
|
|
|
# If this is already a valid Cloud ID, return it
|
|
if is_valid_uuid:
|
|
return cast(str, cloud_id)
|
|
|
|
# If not, it's possibly a Cloud name, so we try to match that.
|
|
if isinstance(cloud_id, str) and cloud_id != "":
|
|
return await get_cloud_id_by_cloud_name(context, cloud_name=cloud_id)
|
|
|
|
# As a last resort, try to get a unique Cloud ID from the available Atlassian Clouds
|
|
return await get_unique_cloud_id(context)
|
|
|
|
|
|
async def get_cloud_id_by_cloud_name(context: ToolContext, cloud_name: str) -> str:
|
|
from arcade_jira.tools.cloud import get_available_atlassian_clouds # Avoid circular import
|
|
|
|
response = await get_available_atlassian_clouds(context)
|
|
clouds = response["clouds_available"]
|
|
|
|
for cloud in clouds:
|
|
if (
|
|
# Case-insensitive match in case of cloud names.
|
|
cloud["atlassian_cloud_name"].casefold() == cloud_name.casefold()
|
|
# Match the ID as well just in case. Who knows, Atlassian may start
|
|
# using some weird values as cloud IDs. If the value provided matches
|
|
# an ID in the list of clouds, then it's a match.
|
|
or cloud["atlassian_cloud_id"] == cloud_name
|
|
):
|
|
return cast(str, cloud["atlassian_cloud_id"])
|
|
|
|
message = f"No Atlassian Cloud found matching '{cloud_name}'"
|
|
available_clouds_str = f"Available Atlassian Clouds:\n\n```json\n{json.dumps(clouds)}\n```"
|
|
|
|
raise RetryableToolError(
|
|
message=message,
|
|
developer_message=message,
|
|
additional_prompt_content=available_clouds_str,
|
|
)
|
|
|
|
|
|
async def get_unique_cloud_id(context: ToolContext) -> str:
|
|
from arcade_jira.tools.cloud import get_available_atlassian_clouds # Avoid circular import
|
|
|
|
response = await get_available_atlassian_clouds(context)
|
|
clouds = response["clouds_available"]
|
|
|
|
if len(clouds) == 0:
|
|
message = "No Atlassian Cloud is available. Please authorize an Atlassian Cloud."
|
|
raise ToolExecutionError(
|
|
message=message,
|
|
developer_message=message,
|
|
)
|
|
|
|
if len(clouds) > 1:
|
|
message = (
|
|
"Multiple Atlassian Clouds are available. One Cloud ID has to be selected and provided "
|
|
"in the tool call using the `atlassian_cloud_id` argument."
|
|
)
|
|
raise RetryableToolError(
|
|
message=message,
|
|
developer_message=message,
|
|
additional_prompt_content=(
|
|
f"Available Atlassian Clouds:\n\n```json\n{json.dumps(clouds)}\n```"
|
|
),
|
|
)
|
|
|
|
return cast(str, clouds[0]["atlassian_cloud_id"])
|
|
|
|
|
|
async def check_if_cloud_is_authorized(
|
|
context: ToolContext,
|
|
cloud: dict[str, Any],
|
|
semaphore: asyncio.Semaphore,
|
|
) -> dict[str, Any] | bool:
|
|
"""Confirm whether an Atlassian Cloud is authorized for the current auth token.
|
|
|
|
The Atlassian available-resources endpoint may return Clouds that have not been
|
|
authorized by the current user. This is a known Atlassian OAuth2 API bug [1].
|
|
|
|
We run this check against the '/myself' endpoint to confirm whether the Cloud
|
|
was actually authorized for the current auth token.
|
|
|
|
[1] Reference about the Atlassian API bug:
|
|
https://community.developer.atlassian.com/t/urgent-api-accessible-resources-endpoint-returns-sites-resources-that-are-not-permitted-by-the-user/66899
|
|
Archived (2025-07-22): https://archive.is/0noNX
|
|
"""
|
|
cloud_id = cloud["atlassian_cloud_id"]
|
|
|
|
try:
|
|
async with semaphore, httpx.AsyncClient() as client:
|
|
response = await client.get(
|
|
f"{JIRA_BASE_URL}/{cloud_id}/rest/api/3/myself",
|
|
headers={"Authorization": f"Bearer {context.get_auth_token_or_empty()}"},
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
return cloud
|
|
|
|
elif response.status_code == 429 or response.status_code >= 500:
|
|
response.raise_for_status()
|
|
|
|
else:
|
|
return False
|
|
|
|
except Exception as exc:
|
|
message = (
|
|
f"An error occurred while checking if the Atlassian Cloud with ID '{cloud_id}' "
|
|
"is authorized."
|
|
)
|
|
developer_message = f"{message} Error info: {type(exc).__name__}: {exc!s}"
|
|
|
|
raise ToolExecutionError(
|
|
message=message,
|
|
developer_message=developer_message,
|
|
) from exc
|
|
|
|
# This is necessary otherwise mypy will complain
|
|
else:
|
|
return False
|