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

455 lines
17 KiB
Python

import base64
from typing import Annotated, Any
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Asana
from arcade.sdk.errors import ToolExecutionError
from arcade_asana.constants import TASK_OPT_FIELDS, SortOrder, TaskSortBy
from arcade_asana.models import AsanaClient
from arcade_asana.utils import (
build_task_search_query_params,
get_next_page,
get_project_by_name_or_raise_error,
get_tag_ids,
get_unique_workspace_id_or_raise_error,
handle_new_task_associations,
handle_new_task_tags,
remove_none_values,
validate_date_format,
)
@tool(requires_auth=Asana(scopes=["default"]))
async def get_tasks_without_id(
context: ToolContext,
keywords: Annotated[
str | None, "Keywords to search for tasks. Matches against the task name and description."
] = None,
workspace_id: Annotated[
str | None,
"The workspace ID to search for tasks. Defaults to None. If not provided and the user "
"has only one workspace, it will use that workspace. If not provided and the user has "
"multiple workspaces, it will raise an error listing the available workspaces.",
] = None,
assignee_id: Annotated[
str | None,
"The ID of the user to filter tasks assigned to. "
"Defaults to None (does not filter by assignee).",
] = None,
project: Annotated[
str | None,
"The ID or name of the project to filter tasks. "
"Defaults to None (searches tasks associated to any project or no project).",
] = None,
team_id: Annotated[
str | None,
"Restricts the search to tasks associated to the given team ID. "
"Defaults to None (searches tasks associated to any team).",
] = None,
tags: Annotated[
list[str] | None,
"Restricts the search to tasks associated to the given tags. "
"Each item in the list can be a tag name (e.g. 'My Tag') or a tag ID (e.g. '1234567890'). "
"Defaults to None (searches tasks associated to any tag or no tag).",
] = None,
due_on: Annotated[
str | None,
"Match tasks that are due exactly on this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. "
"Defaults to None (searches tasks due on any date or without a due date).",
] = None,
due_on_or_after: Annotated[
str | None,
"Match tasks that are due on OR AFTER this date. Format: YYYY-MM-DD. Ex: '2025-01-01' "
"Defaults to None (searches tasks due on any date or without a due date).",
] = None,
due_on_or_before: Annotated[
str | None,
"Match tasks that are due on OR BEFORE this date. Format: YYYY-MM-DD. Ex: '2025-01-01' "
"Defaults to None (searches tasks due on any date or without a due date).",
] = None,
start_on: Annotated[
str | None,
"Match tasks that started on this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. "
"Defaults to None (searches tasks started on any date or without a start date).",
] = None,
start_on_or_after: Annotated[
str | None,
"Match tasks that started on OR AFTER this date. Format: YYYY-MM-DD. Ex: '2025-01-01' "
"Defaults to None (searches tasks started on any date or without a start date).",
] = None,
start_on_or_before: Annotated[
str | None,
"Match tasks that started on OR BEFORE this date. Format: YYYY-MM-DD. Ex: '2025-01-01' "
"Defaults to None (searches tasks started on any date or without a start date).",
] = None,
completed: Annotated[
bool | None,
"Match tasks that are completed. Defaults to None (does not filter by completion status).",
] = None,
limit: Annotated[
int,
"The maximum number of tasks to return. Min of 1, max of 100. Defaults to 100.",
] = 100,
sort_by: Annotated[
TaskSortBy,
"The field to sort the tasks by. Defaults to TaskSortBy.MODIFIED_AT.",
] = TaskSortBy.MODIFIED_AT,
sort_order: Annotated[
SortOrder,
"The order to sort the tasks by. Defaults to SortOrder.DESCENDING.",
] = SortOrder.DESCENDING,
) -> Annotated[dict[str, Any], "The tasks that match the query."]:
"""Search for tasks"""
limit = max(1, min(100, limit))
project_id = None
if project:
if project.isnumeric():
project_id = project
else:
project_data = await get_project_by_name_or_raise_error(context, project)
project_id = project_data["id"]
if not workspace_id:
workspace_id = project_data["workspace"]["id"]
tag_ids = await get_tag_ids(context, tags)
client = AsanaClient(context.get_auth_token_or_empty())
validate_date_format("due_on", due_on)
validate_date_format("due_on_or_after", due_on_or_after)
validate_date_format("due_on_or_before", due_on_or_before)
validate_date_format("start_on", start_on)
validate_date_format("start_on_or_after", start_on_or_after)
validate_date_format("start_on_or_before", start_on_or_before)
if not any([workspace_id, project_id, team_id]):
workspace_id = await get_unique_workspace_id_or_raise_error(context)
if not workspace_id and team_id:
from arcade_asana.tools.teams import get_team_by_id
team = await get_team_by_id(context, team_id)
workspace_id = team["organization"]["id"]
response = await client.get(
f"/workspaces/{workspace_id}/tasks/search",
params=build_task_search_query_params(
keywords=keywords,
completed=completed,
assignee_id=assignee_id,
project_id=project_id,
team_id=team_id,
tag_ids=tag_ids,
due_on=due_on,
due_on_or_after=due_on_or_after,
due_on_or_before=due_on_or_before,
start_on=start_on,
start_on_or_after=start_on_or_after,
start_on_or_before=start_on_or_before,
limit=limit,
sort_by=sort_by,
sort_order=sort_order,
),
)
tasks_by_id = {task["id"]: task for task in response["data"]}
tasks = list(tasks_by_id.values())
return {"tasks": tasks, "count": len(tasks)}
@tool(requires_auth=Asana(scopes=["default"]))
async def get_task_by_id(
context: ToolContext,
task_id: Annotated[str, "The ID of the task to get."],
max_subtasks: Annotated[
int,
"The maximum number of subtasks to return. "
"Min of 0 (no subtasks), max of 100. Defaults to 100.",
] = 100,
) -> Annotated[dict[str, Any], "The task with the given ID."]:
"""Get a task by its ID"""
client = AsanaClient(context.get_auth_token_or_empty())
response = await client.get(
f"/tasks/{task_id}",
params={"opt_fields": TASK_OPT_FIELDS},
)
if max_subtasks > 0:
max_subtasks = min(max_subtasks, 100)
subtasks = await get_subtasks_from_a_task(context, task_id=task_id, limit=max_subtasks)
response["data"]["subtasks"] = subtasks["subtasks"]
return {"task": response["data"]}
@tool(requires_auth=Asana(scopes=["default"]))
async def get_subtasks_from_a_task(
context: ToolContext,
task_id: Annotated[str, "The ID of the task to get the subtasks of."],
limit: Annotated[
int,
"The maximum number of subtasks to return. Min of 1, max of 100. Defaults to 100.",
] = 100,
next_page_token: Annotated[
str | None,
"The token to retrieve the next page of subtasks. Defaults to None (start from the first "
"page of subtasks)",
] = None,
) -> Annotated[dict[str, Any], "The subtasks of the task."]:
"""Get the subtasks of a task"""
limit = max(1, min(100, limit))
client = AsanaClient(context.get_auth_token_or_empty())
response = await client.get(
f"/tasks/{task_id}/subtasks",
params=remove_none_values({
"opt_fields": TASK_OPT_FIELDS,
"limit": limit,
"offset": next_page_token,
}),
)
return {
"subtasks": response["data"],
"count": len(response["data"]),
"next_page": get_next_page(response),
}
@tool(requires_auth=Asana(scopes=["default"]))
async def update_task(
context: ToolContext,
task_id: Annotated[str, "The ID of the task to update."],
name: Annotated[
str | None,
"The new name of the task. Defaults to None (does not change the current name).",
] = None,
completed: Annotated[
bool | None,
"The new completion status of the task. "
"Provide True to mark the task as completed, False to mark it as not completed. "
"Defaults to None (does not change the current completion status).",
] = None,
start_date: Annotated[
str | None,
"The new start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. "
"Defaults to None (does not change the current start date).",
] = None,
due_date: Annotated[
str | None,
"The new due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. "
"Defaults to None (does not change the current due date).",
] = None,
description: Annotated[
str | None,
"The new description of the task. "
"Defaults to None (does not change the current description).",
] = None,
assignee_id: Annotated[
str | None,
"The ID of the new user to assign the task to. "
"Provide 'me' to assign the task to the current user. "
"Defaults to None (does not change the current assignee).",
] = None,
) -> Annotated[
dict[str, Any],
"Updates a task in Asana",
]:
"""Updates a task in Asana"""
client = AsanaClient(context.get_auth_token_or_empty())
validate_date_format("start_date", start_date)
validate_date_format("due_date", due_date)
task_data = {
"data": remove_none_values({
"name": name,
"completed": completed,
"due_on": due_date,
"start_on": start_date,
"notes": description,
"assignee": assignee_id,
}),
}
response = await client.put(f"/tasks/{task_id}", json_data=task_data)
return {
"status": {"success": True, "message": "task updated successfully"},
"task": response["data"],
}
@tool(requires_auth=Asana(scopes=["default"]))
async def mark_task_as_completed(
context: ToolContext,
task_id: Annotated[str, "The ID of the task to mark as completed."],
) -> Annotated[dict[str, Any], "The task marked as completed."]:
"""Mark a task in Asana as completed"""
return await update_task(context, task_id, completed=True) # type: ignore[no-any-return]
@tool(requires_auth=Asana(scopes=["default"]))
async def create_task(
context: ToolContext,
name: Annotated[str, "The name of the task"],
start_date: Annotated[
str | None,
"The start date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. "
"Defaults to None.",
] = None,
due_date: Annotated[
str | None,
"The due date of the task in the format YYYY-MM-DD. Example: '2025-01-01'. "
"Defaults to None.",
] = None,
description: Annotated[str | None, "The description of the task. Defaults to None."] = None,
parent_task_id: Annotated[str | None, "The ID of the parent task. Defaults to None."] = None,
workspace_id: Annotated[
str | None, "The ID of the workspace to associate the task to. Defaults to None."
] = None,
project: Annotated[
str | None, "The ID or name of the project to associate the task to. Defaults to None."
] = None,
assignee_id: Annotated[
str | None,
"The ID of the user to assign the task to. "
"Defaults to 'me', which assigns the task to the current user.",
] = "me",
tags: Annotated[
list[str] | None,
"The tags to associate with the task. Multiple tags can be provided in the list. "
"Each item in the list can be a tag name (e.g. 'My Tag') or a tag ID (e.g. '1234567890'). "
"If a tag name does not exist, it will be automatically created with the new task. "
"Defaults to None (no tags associated).",
] = None,
) -> Annotated[
dict[str, Any],
"Creates a task in Asana",
]:
"""Creates a task in Asana
The task must be associated to at least one of the following: parent_task_id, project, or
workspace_id. If none of these are provided and the account has only one workspace, the task
will be associated to that workspace. If the account has multiple workspaces, an error will
be raised with a list of available workspaces.
"""
client = AsanaClient(context.get_auth_token_or_empty())
parent_task_id, project_id, workspace_id = await handle_new_task_associations(
context, parent_task_id, project, workspace_id
)
tag_ids = await handle_new_task_tags(context, tags, workspace_id)
validate_date_format("start_date", start_date)
validate_date_format("due_date", due_date)
task_data = {
"data": remove_none_values({
"name": name,
"due_on": due_date,
"start_on": start_date,
"notes": description,
"parent": parent_task_id,
"projects": [project_id] if project_id else None,
"workspace": workspace_id,
"assignee": assignee_id,
"tags": tag_ids,
}),
}
response = await client.post("tasks", json_data=task_data)
return {
"status": {"success": True, "message": "task successfully created"},
"task": response["data"],
}
@tool(requires_auth=Asana(scopes=["default"]))
async def attach_file_to_task(
context: ToolContext,
task_id: Annotated[str, "The ID of the task to attach the file to."],
file_name: Annotated[
str,
"The name of the file to attach with format extension. E.g. 'Image.png' or 'Report.pdf'.",
],
file_content_str: Annotated[
str | None,
"The string contents of the file to attach. Use this if the file is a text file. "
"Defaults to None.",
] = None,
file_content_base64: Annotated[
str | None,
"The base64-encoded binary contents of the file. "
"Use this for binary files like images or PDFs. Defaults to None.",
] = None,
file_content_url: Annotated[
str | None,
"The URL of the file to attach. Use this if the file is hosted on an external URL. "
"Defaults to None.",
] = None,
file_encoding: Annotated[
str,
"The encoding of the file to attach. Only used with file_content_str. Defaults to 'utf-8'.",
] = "utf-8",
) -> Annotated[dict[str, Any], "The task with the file attached."]:
"""Attaches a file to an Asana task
Provide exactly one of file_content_str, file_content_base64, or file_content_url, never more
than one.
- Use file_content_str for text files (will be encoded using file_encoding)
- Use file_content_base64 for binary files like images, PDFs, etc.
- Use file_content_url if the file is hosted on an external URL
"""
client = AsanaClient(context.get_auth_token_or_empty())
if sum([bool(file_content_str), bool(file_content_base64), bool(file_content_url)]) != 1:
raise ToolExecutionError(
"Provide exactly one of file_content_str, file_content_base64, or file_content_url"
)
data = {
"parent": task_id,
"name": file_name,
"resource_subtype": "asana",
}
if file_content_url is not None:
data["url"] = file_content_url
data["resource_subtype"] = "external"
file_content = None
elif file_content_str is not None:
try:
file_content = file_content_str.encode(file_encoding)
except LookupError as exc:
raise ToolExecutionError(f"Unknown encoding: {file_encoding}") from exc
except Exception as exc:
raise ToolExecutionError(
f"Failed to encode file content string with {file_encoding} 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(f"Failed to decode base64 file content: {exc!s}") from exc
if file_content:
if file_name.lower().endswith(".pdf"):
files = {"file": (file_name, file_content, "application/pdf")}
else:
files = {"file": (file_name, file_content)} # type: ignore[dict-item]
else:
files = None
response = await client.post("/attachments", data=data, files=files)
return {
"status": {"success": True, "message": f"file successfully attached to task {task_id}"},
"response": response["data"],
}