arcade-mcp/toolkits/asana/arcade_asana/utils.py
2025-05-12 14:59:52 -03:00

483 lines
16 KiB
Python

import asyncio
import json
from collections.abc import Awaitable
from datetime import datetime
from typing import Any, Callable, TypeVar, cast
from arcade.sdk import ToolContext
from arcade.sdk.errors import RetryableToolError, ToolExecutionError
from arcade_asana.constants import (
ASANA_MAX_TIMEOUT_SECONDS,
MAX_PROJECTS_TO_SCAN_BY_NAME,
MAX_TAGS_TO_SCAN_BY_NAME,
TASK_OPT_FIELDS,
SortOrder,
TaskSortBy,
)
from arcade_asana.exceptions import PaginationTimeoutError
ToolResponse = TypeVar("ToolResponse", bound=dict[str, Any])
def remove_none_values(data: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in data.items() if v is not None}
def validate_date_format(name: str, date_str: str | None) -> None:
if not date_str:
return
try:
datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
raise ToolExecutionError(f"Invalid {name} date format. Use the format YYYY-MM-DD.")
def build_task_search_query_params(
keywords: str | None,
completed: bool | None,
assignee_id: str | None,
project_id: str | None,
team_id: str | None,
tag_ids: list[str] | None,
due_on: str | None,
due_on_or_after: str | None,
due_on_or_before: str | None,
start_on: str | None,
start_on_or_after: str | None,
start_on_or_before: str | None,
limit: int,
sort_by: TaskSortBy,
sort_order: SortOrder,
) -> dict[str, Any]:
query_params: dict[str, Any] = {
"text": keywords,
"opt_fields": TASK_OPT_FIELDS,
"sort_by": sort_by.value,
"sort_ascending": sort_order == SortOrder.ASCENDING,
"limit": limit,
}
if completed is not None:
query_params["completed"] = completed
if assignee_id:
query_params["assignee.any"] = assignee_id
if project_id:
query_params["projects.any"] = project_id
if team_id:
query_params["team.any"] = team_id
if tag_ids:
query_params["tags.any"] = ",".join(tag_ids)
query_params = add_task_search_date_params(
query_params,
due_on,
due_on_or_after,
due_on_or_before,
start_on,
start_on_or_after,
start_on_or_before,
)
return query_params
def add_task_search_date_params(
query_params: dict[str, Any],
due_on: str | None,
due_on_or_after: str | None,
due_on_or_before: str | None,
start_on: str | None,
start_on_or_after: str | None,
start_on_or_before: str | None,
) -> dict[str, Any]:
"""
Builds the date-related query parameters for task search.
If a date is provided, it will be added to the query parameters. If not, it will be ignored.
"""
if due_on:
query_params["due_on"] = due_on
if due_on_or_after:
query_params["due_on.after"] = due_on_or_after
if due_on_or_before:
query_params["due_on.before"] = due_on_or_before
if start_on:
query_params["start_on"] = start_on
if start_on_or_after:
query_params["start_on.after"] = start_on_or_after
if start_on_or_before:
query_params["start_on.before"] = start_on_or_before
return query_params
async def handle_new_task_associations(
context: ToolContext,
parent_task_id: str | None,
project: str | None,
workspace_id: str | None,
) -> tuple[str | None, str | None, str | None]:
"""
Handles the association of a new task to a parent task, project, or workspace.
If no association is provided, it will try to find a workspace in the user's account.
In case the user has only one workspace, it will use that workspace.
Otherwise, it will raise an error.
If a workspace_id is not provided, but a parent_task_id or a project_id is provided, it will try
to find the workspace associated with the parent task or project.
In each of the two cases explained above, if a workspace is found, the function will return this
value, even if the workspace_id argument was None.
Returns a tuple of (parent_task_id, project_id, workspace_id).
"""
project_id, project_name = (None, None)
if project:
if project.isnumeric():
project_id = project
else:
project_name = project
if project_name:
project_data = await get_project_by_name_or_raise_error(context, project_name)
project_id = project_data["id"]
workspace_id = project_data["workspace"]["id"]
if not any([parent_task_id, project_id, workspace_id]):
workspace_id = await get_unique_workspace_id_or_raise_error(context)
if not workspace_id and parent_task_id:
from arcade_asana.tools.tasks import get_task_by_id # avoid circular imports
response = await get_task_by_id(context, parent_task_id)
workspace_id = response["task"]["workspace"]["id"]
return parent_task_id, project_id, workspace_id
async def get_project_by_name_or_raise_error(
context: ToolContext,
project_name: str,
max_items_to_scan: int = MAX_PROJECTS_TO_SCAN_BY_NAME,
) -> dict[str, Any]:
response = await find_projects_by_name(
context=context,
names=[project_name],
response_limit=100,
max_items_to_scan=max_items_to_scan,
return_projects_not_matched=True,
)
if not response["matches"]["projects"]:
projects = response["not_matched"]["projects"]
projects = [{"name": project["name"], "id": project["id"]} for project in projects]
message = (
f"Project with name '{project_name}' was not found. The search scans up to "
f"{max_items_to_scan} projects. If the user account has a larger number of projects, "
"it's possible that it exists, but the search didn't find it."
)
additional_prompt = f"Projects available: {json.dumps(projects)}"
raise RetryableToolError(
message=message,
developer_message=f"{message} {additional_prompt}",
additional_prompt_content=additional_prompt,
)
elif response["matches"]["count"] > 1:
projects = [
{"name": project["name"], "id": project["id"]}
for project in response["matches"]["projects"]
]
message = "Multiple projects found with the same name. Please provide a project ID instead."
additional_prompt = f"Projects matching the name '{project_name}': {json.dumps(projects)}"
raise RetryableToolError(
message=message,
developer_message=message,
additional_prompt_content=additional_prompt,
)
return cast(dict, response["matches"]["projects"][0])
async def handle_new_task_tags(
context: ToolContext,
tags: list[str] | None,
workspace_id: str | None,
) -> list[str] | None:
if not tags:
return None
tag_ids = []
tag_names = []
for tag in tags:
if tag.isnumeric():
tag_ids.append(tag)
else:
tag_names.append(tag)
if tag_names:
response = await find_tags_by_name(context, tag_names)
tag_ids.extend([tag["id"] for tag in response["matches"]["tags"]])
if response["not_found"]["tags"]:
from arcade_asana.tools.tags import create_tag # avoid circular imports
responses = await asyncio.gather(*[
create_tag(context, name=name, workspace_id=workspace_id)
for name in response["not_found"]["tags"]
])
tag_ids.extend([response["tag"]["id"] for response in responses])
return tag_ids
async def get_tag_ids(
context: ToolContext,
tags: list[str] | None,
max_items_to_scan: int = MAX_TAGS_TO_SCAN_BY_NAME,
) -> list[str] | None:
"""
Returns the IDs of the tags provided in the tags list, which can be either tag IDs or tag names.
If the tags list is empty, it returns None.
"""
tag_ids = []
tag_names = []
if tags:
for tag in tags:
if tag.isnumeric():
tag_ids.append(tag)
else:
tag_names.append(tag)
if tag_names:
searched_tags = await find_tags_by_name(
context=context,
names=tag_names,
max_items_to_scan=max_items_to_scan,
)
if searched_tags["not_found"]["tags"]:
tag_names_not_found = ", ".join(searched_tags["not_found"]["tags"])
raise ToolExecutionError(
f"Tags not found: {tag_names_not_found}. The search scans up to "
f"{max_items_to_scan} tags. If the user account has a larger number of tags, "
"it's possible that the tags exist, but the search didn't find them."
)
tag_ids.extend([tag["id"] for tag in searched_tags["matches"]["tags"]])
return tag_ids if tag_ids else None
async def paginate_tool_call(
tool: Callable[[ToolContext, Any], Awaitable[ToolResponse]],
context: ToolContext,
response_key: str,
max_items: int = 300,
timeout_seconds: int = ASANA_MAX_TIMEOUT_SECONDS,
next_page_token: str | None = None,
**tool_kwargs: Any,
) -> list[ToolResponse]:
results: list[ToolResponse] = []
async def paginate_loop() -> None:
nonlocal results
keep_paginating = True
if "limit" not in tool_kwargs:
tool_kwargs["limit"] = 100
while keep_paginating:
response = await tool(context, **tool_kwargs) # type: ignore[call-arg]
results.extend(response[response_key])
next_page = get_next_page(response)
next_page_token = next_page["next_page_token"]
if not next_page_token or len(results) >= max_items:
keep_paginating = False
else:
tool_kwargs["next_page_token"] = next_page_token
try:
await asyncio.wait_for(paginate_loop(), timeout=timeout_seconds)
except TimeoutError:
raise PaginationTimeoutError(timeout_seconds, tool.__tool_name__) # type: ignore[attr-defined]
else:
return results
async def get_unique_workspace_id_or_raise_error(context: ToolContext) -> str:
# Importing here to avoid circular imports
from arcade_asana.tools.workspaces import list_workspaces
workspaces = await list_workspaces(context)
if len(workspaces["workspaces"]) == 1:
return cast(str, workspaces["workspaces"][0]["id"])
else:
message = "User has multiple workspaces. Please provide a workspace ID."
additional_prompt = f"Workspaces available: {json.dumps(workspaces['workspaces'])}"
raise RetryableToolError(
message=message,
developer_message=message,
additional_prompt_content=additional_prompt,
)
async def find_projects_by_name(
context: ToolContext,
names: list[str],
team_id: list[str] | None = None,
response_limit: int = 100,
max_items_to_scan: int = MAX_PROJECTS_TO_SCAN_BY_NAME,
return_projects_not_matched: bool = False,
) -> dict[str, Any]:
"""Find projects by name using exact match
This function will paginate the list_projects tool call and return the projects that match
the names provided. If the names provided are not found, it will return the names searched for
that did not match any projects.
If return_projects_not_matched is True, it will also return the projects that were scanned,
but did not match any of the names searched for.
Args:
context: The tool context to use in the list_projects tool call.
names: The names of the projects to search for.
team_id: The ID of the team to search for projects in.
response_limit: The maximum number of matched projects to return.
max_items_to_scan: The maximum number of projects to scan while looking for matches.
return_projects_not_matched: Whether to return the projects that were scanned, but did not
match any of the names searched for.
"""
from arcade_asana.tools.projects import list_projects # avoid circular imports
names_lower = {name.casefold() for name in names}
projects = await paginate_tool_call(
tool=list_projects,
context=context,
response_key="projects",
max_items=max_items_to_scan,
timeout_seconds=15,
team_id=team_id,
)
matches: list[dict[str, Any]] = []
not_matched: list[str] = []
for project in projects:
project_name_lower = project["name"].casefold()
if len(matches) >= response_limit:
break
if project_name_lower in names_lower:
matches.append(project)
names_lower.remove(project_name_lower)
else:
not_matched.append(project)
not_found = [name for name in names if name.casefold() in names_lower]
response = {
"matches": {
"projects": matches,
"count": len(matches),
},
"not_found": {
"names": not_found,
"count": len(not_found),
},
}
if return_projects_not_matched:
response["not_matched"] = {
"projects": not_matched,
"count": len(not_matched),
}
return response
async def find_tags_by_name(
context: ToolContext,
names: list[str],
workspace_id: list[str] | None = None,
response_limit: int = 100,
max_items_to_scan: int = MAX_TAGS_TO_SCAN_BY_NAME,
return_tags_not_matched: bool = False,
) -> dict[str, Any]:
"""Find tags by name using exact match
This function will paginate the list_tags tool call and return the tags that match the names
provided. If the names provided are not found, it will return the names searched for that did
not match any tags.
If return_tags_not_matched is True, it will also return the tags that were scanned, but did not
match any of the names searched for.
Args:
context: The tool context to use in the list_tags tool call.
names: The names of the tags to search for.
workspace_id: The ID of the workspace to search for tags in.
response_limit: The maximum number of matched tags to return.
max_items_to_scan: The maximum number of tags to scan while looking for matches.
return_tags_not_matched: Whether to return the tags that were scanned, but did not match
any of the names searched for.
"""
from arcade_asana.tools.tags import list_tags # avoid circular imports
names_lower = {name.casefold() for name in names}
tags = await paginate_tool_call(
tool=list_tags,
context=context,
response_key="tags",
max_items=max_items_to_scan,
timeout_seconds=15,
workspace_id=workspace_id,
)
matches: list[dict[str, Any]] = []
not_matched: list[str] = []
for tag in tags:
tag_name_lower = tag["name"].casefold()
if len(matches) >= response_limit:
break
if tag_name_lower in names_lower:
matches.append(tag)
names_lower.remove(tag_name_lower)
else:
not_matched.append(tag["name"])
not_found = [name for name in names if name.casefold() in names_lower]
response = {
"matches": {
"tags": matches,
"count": len(matches),
},
"not_found": {
"tags": not_found,
"count": len(not_found),
},
}
if return_tags_not_matched:
response["not_matched"] = {
"tags": not_matched,
"count": len(not_matched),
}
return response
def get_next_page(response: dict[str, Any]) -> dict[str, Any]:
try:
token = response["next_page"]["offset"]
except (KeyError, TypeError):
token = None
return {"has_more_pages": token is not None, "next_page_token": token}