arcade-mcp/toolkits/jira/arcade_jira/tools/comments.py
Renato Byrro 30739dc44a
Support for multiple Atlassian Clouds in the Jira Toolkit (#506)
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);
2025-07-23 18:09:54 -03:00

194 lines
7.4 KiB
Python

from typing import Annotated, Any
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Atlassian
from arcade_tdk.errors import ToolExecutionError
from arcade_jira.client import JiraClient
from arcade_jira.constants import IssueCommentOrderBy
from arcade_jira.exceptions import MultipleItemsFoundError, NotFoundError
from arcade_jira.utils import (
add_pagination_to_response,
build_adf_doc,
clean_comment_dict,
find_multiple_unique_users,
remove_none_values,
resolve_cloud_id,
)
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
async def get_comment_by_id(
context: ToolContext,
issue_id: Annotated[str, "The ID or key of the issue to retrieve the comment from."],
comment_id: Annotated[str, "The ID of the comment to retrieve"],
include_adf_content: Annotated[
bool,
"Whether to include the ADF (Atlassian Document Format) content of the comment in the "
"response. Defaults to False (return only the HTML rendered content).",
] = False,
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 comment"]:
"""Get a comment by its ID."""
atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id)
client = JiraClient(context=context, cloud_id=atlassian_cloud_id)
response = await client.get(
f"issue/{issue_id}/comment/{comment_id}",
params={"expand": "renderedBody"},
)
if not response:
return {
"comment": None,
"message": f"No comment found with ID '{comment_id}' in the issue '{issue_id}'.",
"query": {"issue_id": issue_id, "comment_id": comment_id},
}
return {"comment": clean_comment_dict(response, include_adf_content)}
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
async def get_issue_comments(
context: ToolContext,
issue: Annotated[str, "The ID or key of the issue to retrieve"],
limit: Annotated[
int,
"The maximum number of comments to retrieve. Min 1, max 100, default 100.",
] = 100,
offset: Annotated[
int,
"The number of comments to skip. Defaults to 0 (start from the first comment).",
] = 0,
order_by: Annotated[
IssueCommentOrderBy | None,
"The order in which to return the comments. "
f"Defaults to '{IssueCommentOrderBy.CREATED_DATE_DESCENDING.value}' (most recent first).",
] = IssueCommentOrderBy.CREATED_DATE_DESCENDING,
include_adf_content: Annotated[
bool,
"Whether to include the ADF (Atlassian Document Format) content of the comment in the "
"response. Defaults to False (return only the HTML rendered content).",
] = False,
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 issue comments"]:
"""Get the comments of a Jira issue by its ID."""
limit = max(min(limit, 100), 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(
f"issue/{issue}/comment",
params=remove_none_values({
"expand": "renderedBody",
"maxResults": limit,
"startAt": offset,
"orderBy": order_by.to_api_value() if order_by else None,
}),
)
comments = [
clean_comment_dict(comment, include_adf_content)
for comment in api_response["comments"][:limit]
]
response = {
"issue": issue,
"comments": comments,
"isLast": api_response.get("isLast"),
}
return add_pagination_to_response(response, comments, limit, offset)
@tool(
requires_auth=Atlassian(
scopes=[
"write:jira-work", # Needed to add the comment
"read:jira-work", # Needed to get the issue data
"read:jira-user", # Needed to resolve user ID from name or email (mention_users)
],
),
)
async def add_comment_to_issue(
context: ToolContext,
issue: Annotated[str, "The ID or key of the issue to comment on."],
body: Annotated[str, "The body of the comment to add to the issue."],
reply_to_comment: Annotated[
str | None,
"Quote a previous comment as a reply to it. Provide the comment's ID. "
"Must be a comment from the same issue. Defaults to None (no quoted comment).",
] = None,
mention_users: Annotated[
list[str] | None,
"The users to mention in the comment. Provide the user display name, email address, or ID. "
"Ex: 'John Doe' or 'john.doe@example.com'. Defaults to None (no user mentions).",
] = None,
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 comment created"]:
"""Add a comment to a Jira issue."""
if not body:
raise ToolExecutionError(message="Comment body cannot be empty.")
atlassian_cloud_id = await resolve_cloud_id(context, atlassian_cloud_id)
client = JiraClient(context=context, cloud_id=atlassian_cloud_id)
adf_body = build_adf_doc(body)
if mention_users:
try:
users = await find_multiple_unique_users(
context=context,
user_identifiers=mention_users,
exact_match=True,
atlassian_cloud_id=atlassian_cloud_id,
)
except (NotFoundError, MultipleItemsFoundError) as exc:
return {"error": f"Failed to mention user: {exc.message}"}
mentions = [
{
"type": "mention",
"attrs": {"accessLevel": "", "id": user["id"], "text": f"@{user['name']}"},
}
for user in users
]
adf_body["content"][0]["content"] = mentions + adf_body["content"][0]["content"]
if reply_to_comment:
quote_comment = await get_comment_by_id(
context=context,
issue_id=issue,
comment_id=reply_to_comment,
include_adf_content=True,
atlassian_cloud_id=atlassian_cloud_id,
)
if not quote_comment["comment"]:
raise ToolExecutionError(
message=f"Cannot quote comment. No comment found with ID '{reply_to_comment}'."
)
quote = {
"type": "blockquote",
"content": quote_comment["comment"]["adf_body"]["content"],
}
adf_body["content"] = [quote] + adf_body["content"]
response = await client.post(
f"issue/{issue}/comment",
json_data={
"expand": "renderedBody",
"body": adf_body,
},
)
return {
"success": True,
"message": f"Comment successfully created for the issue '{issue}'.",
"comment": {"id": response["id"], "created_at": response["created"]},
}