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);
197 lines
6.5 KiB
Python
197 lines
6.5 KiB
Python
import asyncio
|
|
import json
|
|
import json.decoder
|
|
from dataclasses import dataclass
|
|
from typing import cast
|
|
|
|
import httpx
|
|
from arcade_tdk import ToolContext
|
|
from arcade_tdk.errors import ToolExecutionError
|
|
|
|
from arcade_jira.constants import JIRA_API_VERSION, JIRA_BASE_URL, JIRA_MAX_CONCURRENT_REQUESTS
|
|
from arcade_jira.exceptions import NotFoundError
|
|
|
|
|
|
@dataclass
|
|
class JiraClient:
|
|
context: ToolContext
|
|
cloud_id: str | None
|
|
base_url: str = JIRA_BASE_URL
|
|
api_version: str = JIRA_API_VERSION
|
|
max_concurrent_requests: int = JIRA_MAX_CONCURRENT_REQUESTS
|
|
_semaphore: asyncio.Semaphore | None = None
|
|
|
|
@property
|
|
def auth_token(self) -> str | None:
|
|
return self.context.get_auth_token_or_empty()
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self._semaphore:
|
|
cached_semaphore = getattr(self.context, "_global_jira_client_semaphore", None)
|
|
|
|
# If a semaphore was already cached in the context, we use it. Some tools
|
|
# may call other tools. Each tool will instantiate its own JiraClient.
|
|
# This is necessary to ensure that all instances will respect the
|
|
# concurrency limit.
|
|
if cached_semaphore:
|
|
self._semaphore = cached_semaphore
|
|
else:
|
|
self._semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
|
self.context._global_jira_client_semaphore = self._semaphore # type: ignore[attr-defined]
|
|
|
|
self.base_url = self.base_url.rstrip("/")
|
|
self.api_version = self.api_version.strip("/")
|
|
|
|
async def _build_url(self, endpoint: str) -> str:
|
|
return (
|
|
f"{self.base_url}/{self.cloud_id or ''}"
|
|
f"/rest/api/{self.api_version}/{endpoint.lstrip('/')}"
|
|
)
|
|
|
|
def _build_error_messages(self, response: httpx.Response) -> tuple[str, str | None]:
|
|
try:
|
|
data = response.json()
|
|
developer_message = None
|
|
|
|
if "errorMessages" in data:
|
|
if len(data["errorMessages"]) == 1:
|
|
error_message = cast(str, data["errorMessages"][0])
|
|
elif "errors" in data:
|
|
error_message = json.dumps(data["errors"])
|
|
else:
|
|
error_message = "Unknown error"
|
|
|
|
elif "message" in data:
|
|
error_message = cast(str, data["message"])
|
|
|
|
else:
|
|
error_message = json.dumps(data)
|
|
|
|
except Exception as e:
|
|
error_message = "Failed to parse Jira error response"
|
|
developer_message = (
|
|
f"Failed to parse Jira error response: {type(e).__name__}: {e!s}. "
|
|
f"API Response: {response.text}"
|
|
)
|
|
|
|
return error_message, developer_message
|
|
|
|
async def _raise_for_status(self, response: httpx.Response) -> None:
|
|
if response.status_code < 300:
|
|
return
|
|
|
|
error_message, developer_message = self._build_error_messages(response)
|
|
|
|
if response.status_code == 404:
|
|
raise NotFoundError(error_message, developer_message)
|
|
|
|
raise ToolExecutionError(error_message, developer_message)
|
|
|
|
def _set_request_body(self, kwargs: dict, data: dict | None, json_data: dict | None) -> dict:
|
|
if data and json_data:
|
|
raise ValueError("Cannot provide both data and json_data") # noqa: TRY003
|
|
|
|
if data:
|
|
kwargs["data"] = data
|
|
|
|
elif json_data:
|
|
kwargs["json"] = json_data
|
|
|
|
return kwargs
|
|
|
|
def _format_response_dict(self, response: httpx.Response) -> dict:
|
|
try:
|
|
return cast(dict, response.json())
|
|
except (UnicodeDecodeError, json.decoder.JSONDecodeError):
|
|
return {"text": response.text}
|
|
|
|
async def get(
|
|
self,
|
|
endpoint: str,
|
|
params: dict | None = None,
|
|
headers: dict | None = None,
|
|
) -> dict:
|
|
default_headers = {
|
|
"Authorization": f"Bearer {self.auth_token}",
|
|
"Accept": "application/json",
|
|
}
|
|
headers = {**default_headers, **(headers or {})}
|
|
|
|
kwargs = {
|
|
"url": await self._build_url(endpoint),
|
|
"headers": headers,
|
|
}
|
|
|
|
if params:
|
|
kwargs["params"] = params
|
|
|
|
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
|
|
response = await client.get(**kwargs) # type: ignore[arg-type]
|
|
await self._raise_for_status(response)
|
|
|
|
return self._format_response_dict(response)
|
|
|
|
async def post(
|
|
self,
|
|
endpoint: str,
|
|
data: dict | None = None,
|
|
json_data: dict | None = None,
|
|
files: dict | None = None,
|
|
headers: dict | None = None,
|
|
) -> dict:
|
|
default_headers = {
|
|
"Authorization": f"Bearer {self.auth_token}",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
if files is None and json_data is not None:
|
|
default_headers["Content-Type"] = "application/json"
|
|
|
|
headers = {**default_headers, **(headers or {})}
|
|
|
|
kwargs = {
|
|
"url": await self._build_url(endpoint),
|
|
"headers": headers,
|
|
}
|
|
|
|
if files is not None:
|
|
kwargs["files"] = files
|
|
if data is not None:
|
|
kwargs["data"] = data
|
|
else:
|
|
kwargs = self._set_request_body(kwargs, data, json_data)
|
|
|
|
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
|
|
response = await client.post(**kwargs) # type: ignore[arg-type]
|
|
await self._raise_for_status(response)
|
|
|
|
return self._format_response_dict(response)
|
|
|
|
async def put(
|
|
self,
|
|
endpoint: str,
|
|
data: dict | None = None,
|
|
json_data: dict | None = None,
|
|
params: dict | None = None,
|
|
headers: dict | None = None,
|
|
) -> dict:
|
|
headers = headers or {}
|
|
headers["Authorization"] = f"Bearer {self.auth_token}"
|
|
headers["Content-Type"] = "application/json"
|
|
headers["Accept"] = "application/json"
|
|
|
|
kwargs = {
|
|
"url": await self._build_url(endpoint),
|
|
"headers": headers,
|
|
}
|
|
|
|
kwargs = self._set_request_body(kwargs, data, json_data)
|
|
|
|
if params:
|
|
kwargs["params"] = params
|
|
|
|
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
|
|
response = await client.put(**kwargs) # type: ignore[arg-type]
|
|
await self._raise_for_status(response)
|
|
|
|
return self._format_response_dict(response)
|