arcade-mcp/toolkits/jira/arcade_jira/utils.py
Sam Partee b6b4cd0a4c
🏗️ Restructure: Multi-Package Architecture + uv Migration (#412)
### Overview
Major restructuring from monolithic `arcade-ai` package to modular
library architecture with standardized uv-based dependency management.

![arcade-ai Monorepo
(2)](https://github.com/user-attachments/assets/25f102b0-bb87-4a04-9701-d227d05664b1)

### New Package Structure
- **`arcade-tdk`** - Lightweight toolkit development kit (core
decorators, auth)
- **`arcade-core`** - Core execution engine and catalog functionality  
- **`arcade-serve`** - FastAPI/MCP server components
- **`arcade-ai`** - Meta package that includes CLI functionality.
Optionally include evals via the `evals` extra. Optionally include all
packages via the `all` extra.

### Key Benefits
- **Lighter Dependencies**: Toolkits now depend only on `arcade-tdk` (~2
deps) vs full `arcade-ai` (~30+ deps)
- **Faster Builds**: uv provides 10-100x faster dependency resolution
and installation
- **Better Modularity**: Clear separation of concerns, consumers import
only what they need
- **Standard Tooling**: Eliminates custom poetry scripts, uses standard
Python packaging

### Migration Impact
- All 20 toolkits converted from poetry → uv with `arcade-tdk`
dependencies plus `arcade-ai[evals]` and `arcade-serve` dev
dependencies. When developing locally, devs should install toolkits via
`make install-local`.
- Modern Python 3.10+ type hints throughout
- Standardized build system with hatchling backend
- Enhanced Makefile with robust toolkit management commands
- Removed `arcade dev` CLI command
- Reduce the number of files created by `arcade new` and add an option
to not generate a tests and evals folder.

This foundation enables faster development cycles and cleaner dependency
chains for the growing toolkit ecosystem.

### Todo After this PR is merged
- [ ] Post-merge workflow(s) (release & publish containers, etc)
- [ ] Release order plan. @EricGustin suggests releasing in the
following order:
    1. `arcade-core` version 0.1.0
    2. `arcade-serve` version 0.1.0 and `arcade-tdk` version 0.1.0
    3. `arcade-ai` version 2.0.0
4. Patch release for all toolkits (all changes in toolkits are internal
refactors)
- [ ] [Update docs](https://github.com/ArcadeAI/docs/pull/318)

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
2025-06-11 16:48:17 -07:00

1144 lines
34 KiB
Python

import asyncio
import base64
import json
import mimetypes
from collections.abc import Callable
from contextlib import suppress
from datetime import date, datetime
from typing import Any, cast
from arcade_tdk import ToolContext
from arcade_tdk.errors import ToolExecutionError
from arcade_jira.constants import 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, cloud_name: str | None = None) -> 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"], cloud_name)
if fields["creator"]:
fields["creator"] = clean_user_dict(fields["creator"], cloud_name)
if fields["reporter"]:
fields["reporter"] = clean_user_dict(fields["reporter"], cloud_name)
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, cloud_name)
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",
],
)
fields["url"] = build_issue_url(cloud_name, fields["project"]["key"], fields["key"])
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, cloud_name: str | None = None) -> dict:
data = {
"id": project["id"],
"key": project["key"],
"name": project["name"],
}
data["url"] = build_project_url(cloud_name, project["key"])
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, cloud_name: str | None = None) -> dict:
data = {
"id": user["accountId"],
"name": user["displayName"],
"active": user["active"],
}
data["url"] = build_user_url(cloud_name, user["accountId"])
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, cloud_name: str | None = None) -> dict:
return {
"id": attachment["id"],
"filename": attachment["filename"],
"mime_type": attachment["mimeType"],
"size": {"bytes": attachment["size"]},
"author": clean_user_dict(attachment["author"], cloud_name),
}
def clean_priority_scheme_dict(scheme: dict, cloud_name: str | None = None) -> 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, cloud_name) 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,
) -> 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,
)
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, user_id=user_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,
) -> 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, project=project_identifier)
if response.get("project"):
return cast(dict, response["project"])
# If not found, search by name
response = await search_projects(context, keywords=project_identifier)
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,
) -> 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, priority_identifier)
if response.get("priority"):
return cast(dict, response["priority"])
# If not found, search by name
response = await list_priorities_available_to_a_project(context, project_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,
) -> 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, issue_type_identifier)
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, project_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,
) -> 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, user_identifier)
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, name_or_email=user_identifier, enforce_exact_match=True
)
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) -> 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",
)
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) -> list[dict]:
"""Get all priority schemes."""
# Avoid circular import
from arcade_jira.tools.priorities import list_priority_schemes
return await paginate_all_items(context, list_priority_schemes, "priority_schemes")
async def paginate_all_priorities_by_priority_scheme(
context: ToolContext,
scheme_id: str,
) -> 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,
)
async def paginate_all_issue_types(context: ToolContext, project_identifier: str) -> list[dict]:
"""Get all issue types associated with a project."""
# Avoid circular import
from arcade_jira.tools.issues import list_issue_types_by_project
return await paginate_all_items(
context,
list_issue_types_by_project,
"issue_types",
project=project_identifier,
)
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,
) -> 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, project, parent_issue
)
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, issue_type, project_data)
if error:
return error, project_data, issue_type_data, priority_data, parent_issue_data
error, priority_data = await resolve_issue_priority(context, priority, project_data)
if error:
return error, project_data, issue_type_data, priority_data, parent_issue_data
error, parent_issue_data = await resolve_parent_issue(context, parent_issue)
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,
) -> 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, issue_type, project_data["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,
) -> tuple[dict[str, Any] | None, str | dict[str, Any] | None]:
if priority == "":
return None, ""
elif priority:
try:
priority_data = await find_unique_priority(context, priority, project_data["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,
) -> 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, parent_issue)
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,
) -> 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, parent_issue_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, project)
except JiraToolExecutionError as exc:
return {"error": exc.message}
return project_data
async def resolve_issue_users(
context: ToolContext,
assignee: str | None,
reporter: str | 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, assignee)
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, reporter)
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],
) -> 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)
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"],
)
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, scheme_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
def build_issue_url(cloud_name: str | None, issue_id: str, issue_key: str) -> str | None:
if not cloud_name:
return None
return f"https://{cloud_name}.atlassian.net/jira/software/projects/{issue_id}/list?selectedIssue={issue_key}"
def build_project_url(cloud_name: str | None, project_key: str) -> str | None:
if not cloud_name:
return None
return f"https://{cloud_name}.atlassian.net/jira/software/projects/{project_key}/summary"
def build_user_url(cloud_name: str | None, user_id: str) -> str | None:
if not cloud_name:
return None
return f"https://{cloud_name}.atlassian.net/jira/people/{user_id}"