Adds `Jira.GetAvailableAtlassianClouds` tool, which provides a list of clouds available (checking which Clouds were actually authorized by the current auth token). Refactors the interface of every tool to accept an `atlassian_cloud_id` argument (when not provided, try to get a unique cloud ID - if multiple are available, raises a Retryable error with the list of Clouds available instructing to select one). Gets rid of all caching. Now storing the global semaphore to the context object. The global semaphore is important because some tools depend on others, and each tool instantiates its own Jira HTTP client. Storing the semaphore in the context object ensures that all HTTP clients will respect the concurrency limit. Removes from tool responses the Atlassian URLs linking to objects in the Jira GUI (users, projects, issues, etc. We do not keep track of the cloud name anymore, which is required to build the objects' URLs. Extends/refactors unit tests accordingly. Evals checking LLM behavior when: - a cloud ID is explicitly mentioned in the prompt; - no cloud ID is mentioned; - a "multiple clouds available" error is raised and the user is prompted to pick one; - user request triggers another tool call after having previously picked a cloud ID (in the same chat context);
109 lines
4 KiB
Python
109 lines
4 KiB
Python
from typing import Annotated, Any, cast
|
|
|
|
from arcade_tdk import ToolContext, tool
|
|
from arcade_tdk.auth import Atlassian
|
|
|
|
from arcade_jira.client import JiraClient
|
|
from arcade_jira.exceptions import NotFoundError
|
|
from arcade_jira.utils import (
|
|
add_pagination_to_response,
|
|
clean_project_dict,
|
|
remove_none_values,
|
|
resolve_cloud_id,
|
|
)
|
|
|
|
|
|
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
|
|
async def list_projects(
|
|
context: ToolContext,
|
|
limit: Annotated[
|
|
int, "The maximum number of projects to return. Min of 1, Max of 50. Defaults to 50."
|
|
] = 50,
|
|
offset: Annotated[
|
|
int, "The number of projects to skip. Defaults to 0 (starts from the first project)"
|
|
] = 0,
|
|
atlassian_cloud_id: Annotated[
|
|
str | None,
|
|
"The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has "
|
|
"a single cloud authorized, the tool will use that. Otherwise, an error will be raised.",
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Information about the projects"]:
|
|
"""Browse projects available in Jira."""
|
|
atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id)
|
|
return cast(
|
|
dict[str, Any],
|
|
await search_projects(
|
|
context=context,
|
|
keywords=None,
|
|
limit=limit,
|
|
offset=offset,
|
|
atlassian_cloud_id=atlassian_cloud_id,
|
|
),
|
|
)
|
|
|
|
|
|
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
|
|
async def search_projects(
|
|
context: ToolContext,
|
|
keywords: Annotated[
|
|
str | None,
|
|
"The keywords to search for projects. Matches against project name and key "
|
|
"(case insensitive). Defaults to None (no keywords filter).",
|
|
] = None,
|
|
limit: Annotated[
|
|
int, "The maximum number of projects to return. Min of 1, Max of 50. Defaults to 50."
|
|
] = 50,
|
|
offset: Annotated[
|
|
int, "The number of projects to skip. Defaults to 0 (starts from the first project)"
|
|
] = 0,
|
|
atlassian_cloud_id: Annotated[
|
|
str | None,
|
|
"The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has "
|
|
"a single cloud authorized, the tool will use that. Otherwise, an error will be raised.",
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Information about the projects"]:
|
|
"""Get the details of all Jira projects."""
|
|
limit = max(min(limit, 50), 1)
|
|
atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id)
|
|
client = JiraClient(context=context, cloud_id=atlassian_cloud_id)
|
|
api_response = await client.get(
|
|
"/project/search",
|
|
params=remove_none_values({
|
|
"expand": ",".join([
|
|
"description",
|
|
"url",
|
|
]),
|
|
"maxResults": limit,
|
|
"startAt": offset,
|
|
"query": keywords,
|
|
}),
|
|
)
|
|
|
|
projects = [clean_project_dict(project) for project in api_response["values"]]
|
|
response = {
|
|
"projects": projects,
|
|
"isLast": api_response.get("isLast"),
|
|
}
|
|
return add_pagination_to_response(response, projects, limit, offset)
|
|
|
|
|
|
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
|
|
async def get_project_by_id(
|
|
context: ToolContext,
|
|
project: Annotated[str, "The ID or key of the project to retrieve"],
|
|
atlassian_cloud_id: Annotated[
|
|
str | None,
|
|
"The ID of the Atlassian Cloud to use (defaults to None). If not provided and the user has "
|
|
"a single cloud authorized, the tool will use that. Otherwise, an error will be raised.",
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Information about the project"]:
|
|
"""Get the details of a Jira project by its ID or key."""
|
|
atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id)
|
|
client = JiraClient(context=context, cloud_id=atlassian_cloud_id)
|
|
|
|
try:
|
|
response = await client.get(f"project/{project}")
|
|
except NotFoundError:
|
|
return {"error": f"Project not found: {project}"}
|
|
|
|
return {"project": clean_project_dict(response)}
|