### Overview Major restructuring from monolithic `arcade-ai` package to modular library architecture with standardized uv-based dependency management.  ### 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>
1144 lines
34 KiB
Python
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}"
|