arcade-mcp/toolkits/jira/arcade_jira/tools/comments.py
Sam Partee b6b4cd0a4c
🏗️ Restructure: Multi-Package Architecture + uv Migration (#412)
### Overview
Major restructuring from monolithic `arcade-ai` package to modular
library architecture with standardized uv-based dependency management.

![arcade-ai Monorepo
(2)](https://github.com/user-attachments/assets/25f102b0-bb87-4a04-9701-d227d05664b1)

### New Package Structure
- **`arcade-tdk`** - Lightweight toolkit development kit (core
decorators, auth)
- **`arcade-core`** - Core execution engine and catalog functionality  
- **`arcade-serve`** - FastAPI/MCP server components
- **`arcade-ai`** - Meta package that includes CLI functionality.
Optionally include evals via the `evals` extra. Optionally include all
packages via the `all` extra.

### Key Benefits
- **Lighter Dependencies**: Toolkits now depend only on `arcade-tdk` (~2
deps) vs full `arcade-ai` (~30+ deps)
- **Faster Builds**: uv provides 10-100x faster dependency resolution
and installation
- **Better Modularity**: Clear separation of concerns, consumers import
only what they need
- **Standard Tooling**: Eliminates custom poetry scripts, uses standard
Python packaging

### Migration Impact
- All 20 toolkits converted from poetry → uv with `arcade-tdk`
dependencies plus `arcade-ai[evals]` and `arcade-serve` dev
dependencies. When developing locally, devs should install toolkits via
`make install-local`.
- Modern Python 3.10+ type hints throughout
- Standardized build system with hatchling backend
- Enhanced Makefile with robust toolkit management commands
- Removed `arcade dev` CLI command
- Reduce the number of files created by `arcade new` and add an option
to not generate a tests and evals folder.

This foundation enables faster development cycles and cleaner dependency
chains for the growing toolkit ecosystem.

### Todo After this PR is merged
- [ ] Post-merge workflow(s) (release & publish containers, etc)
- [ ] Release order plan. @EricGustin suggests releasing in the
following order:
    1. `arcade-core` version 0.1.0
    2. `arcade-serve` version 0.1.0 and `arcade-tdk` version 0.1.0
    3. `arcade-ai` version 2.0.0
4. Patch release for all toolkits (all changes in toolkits are internal
refactors)
- [ ] [Update docs](https://github.com/ArcadeAI/docs/pull/318)

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
2025-06-11 16:48:17 -07:00

164 lines
6 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,
)
@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,
) -> Annotated[dict[str, Any], "Information about the comment"]:
"""Get a comment by its ID."""
client = JiraClient(context.get_auth_token_or_empty())
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,
) -> 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)
client = JiraClient(context.get_auth_token_or_empty())
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,
) -> 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.")
client = JiraClient(context.get_auth_token_or_empty())
adf_body = build_adf_doc(body)
if mention_users:
try:
users = await find_multiple_unique_users(context, mention_users, exact_match=True)
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, issue, reply_to_comment, True)
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"]},
}