483 lines
16 KiB
Python
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}
|