### Overview Major restructuring from monolithic `arcade-ai` package to modular library architecture with standardized uv-based dependency management.  ### 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>
791 lines
30 KiB
Python
791 lines
30 KiB
Python
from typing import Annotated, Any, cast
|
|
|
|
from arcade_tdk import ToolContext, tool
|
|
from arcade_tdk.auth import Atlassian
|
|
|
|
import arcade_jira.cache as cache
|
|
from arcade_jira.client import JiraClient
|
|
from arcade_jira.exceptions import JiraToolExecutionError, MultipleItemsFoundError, NotFoundError
|
|
from arcade_jira.utils import (
|
|
add_pagination_to_response,
|
|
build_adf_doc,
|
|
build_issue_update_request_body,
|
|
build_search_issues_jql,
|
|
clean_issue_dict,
|
|
clean_issue_type_dict,
|
|
clean_labels,
|
|
convert_date_string_to_date,
|
|
extract_id,
|
|
find_unique_project,
|
|
get_single_project,
|
|
remove_none_values,
|
|
resolve_issue_users,
|
|
validate_issue_args,
|
|
)
|
|
|
|
|
|
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
|
|
async def list_issue_types_by_project(
|
|
context: ToolContext,
|
|
project: Annotated[
|
|
str,
|
|
"The project to get issue types for. Provide a project name, key, or ID. If a "
|
|
"project name is provided, the tool will try to find a unique exact match among the "
|
|
"available projects.",
|
|
],
|
|
limit: Annotated[
|
|
int,
|
|
"The maximum number of issue types to retrieve. Min of 1, max of 200. Defaults to 200.",
|
|
] = 200,
|
|
offset: Annotated[
|
|
int,
|
|
"The number of issue types to skip. Defaults to 0 (start from the first issue type).",
|
|
] = 0,
|
|
) -> Annotated[
|
|
dict[str, Any], "Information about the issue types available for the specified project."
|
|
]:
|
|
"""Get the list of issue types (e.g. 'Task', 'Epic', etc.) available to a given project."""
|
|
limit = max(1, min(limit, 200))
|
|
client = JiraClient(context.get_auth_token_or_empty())
|
|
|
|
try:
|
|
project_data = await find_unique_project(context, project)
|
|
except JiraToolExecutionError as error:
|
|
return {"error": error.message}
|
|
|
|
project_id = project_data["id"]
|
|
|
|
api_response = await client.get(
|
|
f"/issue/createmeta/{project_id}/issuetypes",
|
|
params={
|
|
"maxResults": limit,
|
|
"startAt": offset,
|
|
},
|
|
)
|
|
issue_types = [clean_issue_type_dict(issue_type) for issue_type in api_response["issueTypes"]]
|
|
response = {
|
|
"project": {
|
|
"id": project_data["id"],
|
|
"key": project_data["key"],
|
|
"name": project_data["name"],
|
|
},
|
|
"issue_types": issue_types,
|
|
"isLast": api_response.get("isLast"),
|
|
}
|
|
return add_pagination_to_response(response, issue_types, limit, offset)
|
|
|
|
|
|
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
|
|
async def get_issue_type_by_id(
|
|
context: ToolContext,
|
|
issue_type_id: Annotated[str, "The ID of the issue type to retrieve"],
|
|
) -> Annotated[dict, "Information about the issue type"]:
|
|
"""Get the details of a Jira issue type by its ID."""
|
|
client = JiraClient(context.get_auth_token_or_empty())
|
|
try:
|
|
response = await client.get(f"issuetype/{issue_type_id}")
|
|
except NotFoundError:
|
|
return {"error": f"Issue type not found with ID '{issue_type_id}'."}
|
|
return {"issue_type": clean_issue_type_dict(response)}
|
|
|
|
|
|
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
|
|
async def get_issue_by_id(
|
|
context: ToolContext,
|
|
issue: Annotated[str, "The ID or key of the issue to retrieve"],
|
|
) -> Annotated[dict[str, Any], "Information about the issue"]:
|
|
"""Get the details of a Jira issue by its ID."""
|
|
client = JiraClient(context.get_auth_token_or_empty())
|
|
try:
|
|
response = await client.get(
|
|
f"issue/{issue}",
|
|
params={"expand": "renderedFields"},
|
|
)
|
|
except NotFoundError:
|
|
return {"error": f"Issue not found with ID/key '{issue}'."}
|
|
|
|
cloud_name = cache.get_cloud_name(context.get_auth_token_or_empty())
|
|
return {"issue": clean_issue_dict(response, cloud_name)}
|
|
|
|
|
|
# NOTE: This is not named `search_issues` because sometimes LLM's won't realize they can
|
|
# search for an issue if they don't have the ID (hence the `without_id` in the name). There's
|
|
# an alias for this tool named `search_issues_without_jql`, and also another tool to search using
|
|
# JQL, named `search_issues_with_jql`.
|
|
@tool(
|
|
requires_auth=Atlassian(
|
|
scopes=[
|
|
"read:jira-work", # Needed to search for issues
|
|
"read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter)
|
|
"manage:jira-configuration", # Needed to resolve priority ID from name
|
|
]
|
|
)
|
|
)
|
|
async def get_issues_without_id(
|
|
context: ToolContext,
|
|
keywords: Annotated[
|
|
str | None,
|
|
"Keywords to search for issues. Matches against the issue "
|
|
"name, description, comments, and any custom field of type text. "
|
|
"Defaults to None (no keywords filtering).",
|
|
] = None,
|
|
due_from: Annotated[
|
|
str | None,
|
|
"Match issues due on or after this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. "
|
|
"Defaults to None (no due date filtering).",
|
|
] = None,
|
|
due_until: Annotated[
|
|
str | None,
|
|
"Match issues due on or before this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. "
|
|
"Defaults to None (no due date filtering).",
|
|
] = None,
|
|
status: Annotated[
|
|
str | None,
|
|
"Match issues that are in this status. Provide a status name. "
|
|
"Ex: 'To Do', 'In Progress', 'Done'. Defaults to None (any status).",
|
|
] = None,
|
|
priority: Annotated[
|
|
str | None,
|
|
"Match issues that have this priority. Provide a priority name. E.g. 'Highest'. "
|
|
"Defaults to None (any priority).",
|
|
] = None,
|
|
assignee: Annotated[
|
|
str | None,
|
|
"Match issues that are assigned to this user. Provide the user's name or email address. "
|
|
"Ex: 'John Doe' or 'john.doe@example.com'. Defaults to None (any assignee).",
|
|
] = None,
|
|
project: Annotated[
|
|
str | None,
|
|
"Match issues that are associated with this project. Provide the project's name, ID, or "
|
|
"key. If a project name is provided, the tool will try to find a unique exact match among "
|
|
"the available projects. Defaults to None (search across all projects).",
|
|
] = None,
|
|
issue_type: Annotated[
|
|
str | None,
|
|
"Match issues that are of this issue type. Provide an issue type name or ID. "
|
|
"E.g. 'Task', 'Epic', '12345'. If a name is provided, the tool will try to find a unique "
|
|
"exact match among the available issue types. Defaults to None (any issue type).",
|
|
] = None,
|
|
labels: Annotated[
|
|
list[str] | None,
|
|
"Match issues that are in these labels. Defaults to None (any label).",
|
|
] = None,
|
|
parent_issue: Annotated[
|
|
str | None,
|
|
"Match issues that are a child of this issue. Provide the issue's ID or key. "
|
|
"Defaults to None (no parent issue filtering).",
|
|
] = None,
|
|
limit: Annotated[
|
|
int,
|
|
"The maximum number of issues to retrieve. Min 1, max 100, default 50.",
|
|
] = 50,
|
|
next_page_token: Annotated[
|
|
str | None,
|
|
"The token to use to get the next page of issues. Defaults to None (first page).",
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]:
|
|
"""Search for Jira issues when you don't have the issue ID(s).
|
|
|
|
All text-based arguments (keywords, assignee, project, labels) are case-insensitive.
|
|
|
|
ALWAYS PREFER THIS TOOL OVER THE `Jira.SearchIssuesWithJql` TOOL, UNLESS IT'S ABSOLUTELY
|
|
NECESSARY TO USE A JQL QUERY TO FILTER IN A WAY THAT IS NOT SUPPORTED BY THIS TOOL.
|
|
"""
|
|
limit = max(1, min(limit, 100))
|
|
|
|
client = JiraClient(context.get_auth_token_or_empty())
|
|
|
|
due_from_date = convert_date_string_to_date(due_from) if due_from else None
|
|
due_until_date = convert_date_string_to_date(due_until) if due_until else None
|
|
|
|
jql = build_search_issues_jql(
|
|
keywords=keywords,
|
|
due_from=due_from_date,
|
|
due_until=due_until_date,
|
|
status=status,
|
|
priority=priority,
|
|
assignee=assignee,
|
|
project=project,
|
|
issue_type=issue_type,
|
|
labels=labels,
|
|
parent_issue=parent_issue,
|
|
)
|
|
|
|
if not jql:
|
|
raise JiraToolExecutionError(
|
|
message="No search criteria provided. Please provide at least one argument."
|
|
)
|
|
|
|
body = {
|
|
"jql": jql,
|
|
"maxResults": limit,
|
|
"nextPageToken": next_page_token,
|
|
"fields": ["*all"],
|
|
"expand": "renderedFields",
|
|
}
|
|
response = await client.post("search/jql", json_data=body)
|
|
|
|
pagination = {
|
|
"limit": limit,
|
|
"total_results": len(response["issues"]),
|
|
}
|
|
|
|
if response.get("nextPageToken"):
|
|
pagination["next_page_token"] = response["nextPageToken"]
|
|
|
|
cloud_name = cache.get_cloud_name(context.get_auth_token_or_empty())
|
|
|
|
return {
|
|
"issues": [clean_issue_dict(issue, cloud_name) for issue in response["issues"]],
|
|
"pagination": pagination,
|
|
}
|
|
|
|
|
|
@tool(
|
|
requires_auth=Atlassian(
|
|
scopes=[
|
|
"read:jira-work", # Needed to list issues
|
|
"read:jira-user", # Required by the `get_issues_without_id` tool
|
|
"manage:jira-configuration", # Required by the `get_issues_without_id` tool
|
|
],
|
|
),
|
|
)
|
|
async def list_issues(
|
|
context: ToolContext,
|
|
project: Annotated[
|
|
str | None,
|
|
"The project to get issues for. Provide a project ID, key or name. If a project "
|
|
"is not provided and 1) the user has only one project, the tool will use that; 2) the "
|
|
"user has multiple projects, the tool will raise an error listing the available "
|
|
"projects to choose from.",
|
|
] = None,
|
|
limit: Annotated[
|
|
int,
|
|
"The maximum number of issues to retrieve. Min 1, max 100, default 50.",
|
|
] = 50,
|
|
next_page_token: Annotated[
|
|
str | None,
|
|
"The token to use to get the next page of issues. Defaults to None (first page).",
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]:
|
|
"""Get the issues for a given project."""
|
|
if not project:
|
|
project_data = await get_single_project(context)
|
|
project = project_data["id"]
|
|
|
|
return cast(
|
|
dict[str, Any],
|
|
await get_issues_without_id(
|
|
context=context,
|
|
project=project,
|
|
limit=limit,
|
|
next_page_token=next_page_token,
|
|
),
|
|
)
|
|
|
|
|
|
# NOTE: This is an alias for `Jira.GetIssuesWithoutId`. Sometimes LLM's won't realize they can
|
|
# search for an issue if they don't have the ID. Other times, they don't realize they can search
|
|
# without using JQL. The two names are important to cover those cases.
|
|
@tool(
|
|
requires_auth=Atlassian(
|
|
scopes=[
|
|
"read:jira-work", # Needed to search for issues
|
|
"read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter)
|
|
"manage:jira-configuration", # Needed to resolve priority ID from name
|
|
],
|
|
),
|
|
)
|
|
async def search_issues_without_jql(
|
|
context: ToolContext,
|
|
keywords: Annotated[
|
|
str | None,
|
|
"Keywords to search for issues. Matches against the issue "
|
|
"name, description, comments, and any custom field of type text. "
|
|
"Defaults to None (no keywords filtering).",
|
|
] = None,
|
|
due_from: Annotated[
|
|
str | None,
|
|
"Match issues due on or after this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. "
|
|
"Defaults to None (no due date filtering).",
|
|
] = None,
|
|
due_until: Annotated[
|
|
str | None,
|
|
"Match issues due on or before this date. Format: YYYY-MM-DD. Ex: '2025-01-01'. "
|
|
"Defaults to None (no due date filtering).",
|
|
] = None,
|
|
status: Annotated[
|
|
str | None,
|
|
"Match issues that are in this status. Provide a status name. "
|
|
"Ex: 'To Do', 'In Progress', 'Done'. Defaults to None (any status).",
|
|
] = None,
|
|
priority: Annotated[
|
|
str | None,
|
|
"Match issues that have this priority. Provide a priority name. E.g. 'Highest'. "
|
|
"Defaults to None (any priority).",
|
|
] = None,
|
|
assignee: Annotated[
|
|
str | None,
|
|
"Match issues that are assigned to this user. Provide the user's name or email address. "
|
|
"Ex: 'John Doe' or 'john.doe@example.com'. Defaults to None (any assignee).",
|
|
] = None,
|
|
project: Annotated[
|
|
str | None,
|
|
"Match issues that are associated with this project. Provide the project's name, ID, or "
|
|
"key. If a project name is provided, the tool will try to find a unique exact match among "
|
|
"the available projects. Defaults to None (search across all projects).",
|
|
] = None,
|
|
issue_type: Annotated[
|
|
str | None,
|
|
"Match issues that are of this issue type. Provide an issue type name or ID. "
|
|
"E.g. 'Task', 'Epic', '12345'. If a name is provided, the tool will try to find a unique "
|
|
"exact match among the available issue types. Defaults to None (any issue type).",
|
|
] = None,
|
|
labels: Annotated[
|
|
list[str] | None,
|
|
"Match issues that are in these labels. Defaults to None (any label).",
|
|
] = None,
|
|
parent_issue: Annotated[
|
|
str | None,
|
|
"Match issues that are a child of this issue. Provide the issue's ID or key. "
|
|
"Defaults to None (no parent issue filtering).",
|
|
] = None,
|
|
limit: Annotated[
|
|
int,
|
|
"The maximum number of issues to retrieve. Min 1, max 100, default 50.",
|
|
] = 50,
|
|
next_page_token: Annotated[
|
|
str | None,
|
|
"The token to use to get the next page of issues. Defaults to None (first page).",
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]:
|
|
"""Parameterized search for Jira issues (without having to provide a JQL query).
|
|
|
|
THIS TOOL RELEASES LESS CO2 THAN THE `Jira_SearchIssuesWithJql` TOOL. ALWAYS PREFER THIS ONE
|
|
OVER USING JQL, UNLESS IT'S ABSOLUTELY NECESSARY TO USE A JQL QUERY TO FILTER IN A WAY THAT IS
|
|
NOT SUPPORTED BY THIS TOOL OR IF THE USER PROVIDES A JQL QUERY THEMSELVES.
|
|
"""
|
|
return cast(
|
|
dict[str, Any],
|
|
await get_issues_without_id(
|
|
context=context,
|
|
keywords=keywords,
|
|
due_from=due_from,
|
|
due_until=due_until,
|
|
status=status,
|
|
priority=priority,
|
|
assignee=assignee,
|
|
project=project,
|
|
issue_type=issue_type,
|
|
labels=labels,
|
|
parent_issue=parent_issue,
|
|
limit=limit,
|
|
next_page_token=next_page_token,
|
|
),
|
|
)
|
|
|
|
|
|
@tool(requires_auth=Atlassian(scopes=["read:jira-work"]))
|
|
async def search_issues_with_jql(
|
|
context: ToolContext,
|
|
jql: Annotated[str, "The JQL (Jira Query Language) query to search for issues"],
|
|
limit: Annotated[
|
|
int,
|
|
"The maximum number of issues to retrieve. Min of 1, max of 100. Defaults to 50.",
|
|
] = 50,
|
|
next_page_token: Annotated[
|
|
str | None,
|
|
"The token to use to get the next page of issues. Defaults to None (first page).",
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Information about the issues matching the search criteria"]:
|
|
"""Search for Jira issues using a JQL (Jira Query Language) query.
|
|
|
|
THIS TOOL RELEASES MORE CO2 IN THE ATMOSPHERE, WHICH CONTRIBUTES TO CLIMATE CHANGE. ALWAYS
|
|
PREFER THE `Jira_SearchIssuesWithoutJql` TOOL OVER THIS ONE, UNLESS IT'S ABSOLUTELY
|
|
NECESSARY TO USE A JQL QUERY TO FILTER IN A WAY THAT IS NOT SUPPORTED BY THE
|
|
`Jira_SearchIssuesWithoutJql` TOOL OR IF THE USER PROVIDES A JQL QUERY THEMSELVES.
|
|
"""
|
|
limit = max(1, min(limit, 100))
|
|
client = JiraClient(context.get_auth_token_or_empty())
|
|
api_response = await client.post(
|
|
"search/jql",
|
|
json_data={
|
|
"jql": jql,
|
|
"maxResults": limit,
|
|
"nextPageToken": next_page_token,
|
|
"fields": ["*all"],
|
|
"expand": "renderedFields",
|
|
},
|
|
)
|
|
cloud_name = cache.get_cloud_name(context.get_auth_token_or_empty())
|
|
response: dict[str, Any] = {
|
|
"issues": [clean_issue_dict(issue, cloud_name) for issue in api_response["issues"]]
|
|
}
|
|
|
|
if api_response.get("isLast") is not False and api_response.get("nextPageToken"):
|
|
response["pagination"] = {
|
|
"limit": limit,
|
|
"total_results": len(response["issues"]),
|
|
"next_page_token": api_response.get("nextPageToken"),
|
|
}
|
|
else:
|
|
response["pagination"] = {"is_last_page": True}
|
|
|
|
return response
|
|
|
|
|
|
@tool(
|
|
requires_auth=Atlassian(
|
|
scopes=[
|
|
"read:jira-work", # Needed to get the current issue data
|
|
"write:jira-work", # Needed to create the issue
|
|
"read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter)
|
|
"manage:jira-configuration", # Needed to resolve priority ID from name
|
|
],
|
|
),
|
|
)
|
|
async def create_issue(
|
|
context: ToolContext,
|
|
title: Annotated[
|
|
str,
|
|
"The title of the issue.",
|
|
],
|
|
issue_type: Annotated[
|
|
str,
|
|
"The name or ID of the issue type. If a name is provided, the tool will try to find a "
|
|
"unique exact match among the available issue types.",
|
|
],
|
|
project: Annotated[
|
|
str | None,
|
|
"The ID, key or name of the project to associate the issue with. If a name is provided, "
|
|
"the tool will try to find a unique exact match among the available projects. "
|
|
"Defaults to None (no project). If `project` and `parent_issue` are not provided, "
|
|
"the tool will select the single project available. If the user has multiple, an "
|
|
"error will be returned with the available projects to choose from.",
|
|
] = None,
|
|
due_date: Annotated[
|
|
str | None,
|
|
"The due date of the issue. Format: YYYY-MM-DD. Ex: '2025-01-01'. "
|
|
"Defaults to None (no due date).",
|
|
] = None,
|
|
description: Annotated[
|
|
str | None,
|
|
"The description of the issue. Defaults to None (no description).",
|
|
] = None,
|
|
environment: Annotated[
|
|
str | None,
|
|
"The environment of the issue. Defaults to None (no environment).",
|
|
] = None,
|
|
labels: Annotated[
|
|
list[str] | None,
|
|
"The labels of the issue. Defaults to None (no labels). A label cannot contain spaces. "
|
|
"If a label is provided with spaces, they will be trimmed and replaced by underscores.",
|
|
] = None,
|
|
parent_issue: Annotated[
|
|
str | None,
|
|
"The ID or key of the parent issue. Defaults to None (no parent issue). "
|
|
"Must provide at least one of `parent_issue` or `project` arguments.",
|
|
] = None,
|
|
priority: Annotated[
|
|
str | None,
|
|
"The ID or name of the priority to use for the issue. If a name is provided, the tool "
|
|
"will try to find a unique exact match among the available priorities. Defaults to None "
|
|
"(the issue is created with Jira's default priority for the specified project).",
|
|
] = None,
|
|
assignee: Annotated[
|
|
str | None,
|
|
"The name, email or ID of the user to assign the issue to. If a name or email is provided, "
|
|
"the tool will try to find a unique exact match among the available users. "
|
|
"Defaults to None (no assignee).",
|
|
] = None,
|
|
reporter: Annotated[
|
|
str | None,
|
|
"The name, email or ID of the user who is the reporter of the issue. If a name or email is "
|
|
"provided, the tool will try to find a unique exact match among the available users. "
|
|
"Defaults to None (no reporter).",
|
|
] = None,
|
|
) -> Annotated[dict, "The created issue"]:
|
|
"""Create a new Jira issue.
|
|
|
|
Provide a value to one of `project` or `parent_issue` arguments. If `project` and
|
|
`parent_issue` are not provided, the tool will select the single project available.
|
|
If the user has multiple, an error will be returned with the available projects to choose from.
|
|
|
|
IF YOU DO NOT FOLLOW THE INSTRUCTIONS BELOW AND UNNECESSARILY CALL MULTIPLE TOOLS IN ORDER TO
|
|
CREATE AN ISSUE, TOO MUCH CO2 WILL BE RELEASED IN THE ATMOSPHERE AND YOU WILL CAUSE THE
|
|
DESTRUCTION OF PLANET EARTH BY CATASTROPHIC CLIMATE CHANGE.
|
|
|
|
If you have an issue type name, or a project key/name, a priority name, an assignee
|
|
name/key/email, or a reporter name/key/email, DO NOT CALL OTHER TOOLS only to list available
|
|
projects, priorities, issue types, or users. Provide the name, key, or email and the tool
|
|
will figure out the ID, WITHOUT CAUSING CATASTROPHIC CLIMATE CHANGE.
|
|
"""
|
|
project_data: dict[str, Any] | None = None
|
|
|
|
if project is None and parent_issue is None:
|
|
try:
|
|
project_data = await get_single_project(context)
|
|
except (NotFoundError, MultipleItemsFoundError) as exc:
|
|
return {"error": str(exc)}
|
|
else:
|
|
project = project_data["id"]
|
|
|
|
(
|
|
error,
|
|
project_data,
|
|
issue_type_data,
|
|
priority_data,
|
|
parent_data,
|
|
) = await validate_issue_args(context, due_date, project, issue_type, priority, parent_issue)
|
|
if error:
|
|
return error
|
|
|
|
error, assignee_data, reporter_data = await resolve_issue_users(context, assignee, reporter)
|
|
if error:
|
|
return error
|
|
|
|
client = JiraClient(context.get_auth_token_or_empty())
|
|
|
|
request_body = {
|
|
"fields": remove_none_values({
|
|
"summary": title,
|
|
"labels": clean_labels(labels),
|
|
"duedate": due_date,
|
|
"parent": extract_id(parent_data),
|
|
"project": extract_id(project_data),
|
|
"priority": extract_id(priority_data),
|
|
"assignee": extract_id(assignee_data),
|
|
"reporter": extract_id(reporter_data),
|
|
"issuetype": extract_id(issue_type_data),
|
|
}),
|
|
}
|
|
|
|
if environment:
|
|
request_body["fields"]["environment"] = build_adf_doc(environment)
|
|
|
|
if description:
|
|
request_body["fields"]["description"] = build_adf_doc(description)
|
|
|
|
response = await client.post("issue", json_data=request_body)
|
|
|
|
return {
|
|
"status": {
|
|
"success": True,
|
|
"message": "Issue successfully created.",
|
|
},
|
|
"issue": {
|
|
"id": response["id"],
|
|
"key": response["key"],
|
|
"url": response["self"],
|
|
},
|
|
}
|
|
|
|
|
|
@tool(
|
|
requires_auth=Atlassian(
|
|
scopes=[
|
|
"read:jira-work", # Needed to get the current issue labels
|
|
"write:jira-work", # Needed to update the issue
|
|
"read:jira-user", # Required by the `update_issue` tool
|
|
"manage:jira-configuration", # Required by the `update_issue` tool
|
|
],
|
|
),
|
|
)
|
|
async def add_labels_to_issue(
|
|
context: ToolContext,
|
|
issue: Annotated[str, "The ID or key of the issue to update"],
|
|
labels: Annotated[
|
|
list[str],
|
|
"The labels to add to the issue. A label cannot contain spaces. "
|
|
"If a label is provided with spaces, they will be trimmed and replaced by underscores.",
|
|
],
|
|
notify_watchers: Annotated[
|
|
bool,
|
|
"Whether to notify the issue's watchers. Defaults to True (notifies watchers).",
|
|
] = True,
|
|
) -> Annotated[dict, "The updated issue"]:
|
|
"""Add labels to an existing Jira issue."""
|
|
issue_data = await get_issue_by_id(context, issue)
|
|
if issue_data.get("error"):
|
|
return cast(dict, issue_data)
|
|
|
|
labels = cast(list[str], clean_labels(labels))
|
|
current_labels = issue_data["issue"]["labels"]
|
|
response = await update_issue(
|
|
context=context,
|
|
issue=issue_data["issue"]["id"],
|
|
labels=current_labels + labels,
|
|
notify_watchers=notify_watchers,
|
|
)
|
|
return cast(dict, response)
|
|
|
|
|
|
@tool(
|
|
requires_auth=Atlassian(
|
|
scopes=[
|
|
"read:jira-work", # Needed to get the current issue labels
|
|
"write:jira-work", # Needed to update the issue
|
|
"read:jira-user", # Required by the `update_issue` tool
|
|
"manage:jira-configuration", # Required by the `update_issue` tool
|
|
],
|
|
),
|
|
)
|
|
async def remove_labels_from_issue(
|
|
context: ToolContext,
|
|
issue: Annotated[str, "The ID or key of the issue to update"],
|
|
labels: Annotated[list[str], "The labels to remove from the issue (case-insensitive)"],
|
|
notify_watchers: Annotated[
|
|
bool,
|
|
"Whether to notify the issue's watchers. Defaults to True (notifies watchers).",
|
|
] = True,
|
|
) -> Annotated[dict[str, Any], "The updated issue"]:
|
|
"""Remove labels from an existing Jira issue."""
|
|
issue_data = await get_issue_by_id(context, issue)
|
|
if issue_data.get("error"):
|
|
return cast(dict, issue_data)
|
|
|
|
lowercase_labels = [label.casefold() for label in labels]
|
|
current_labels = issue_data["issue"]["labels"]
|
|
new_labels = [label for label in current_labels if label.casefold() not in lowercase_labels]
|
|
response = await update_issue(
|
|
context=context,
|
|
issue=issue_data["issue"]["id"],
|
|
labels=new_labels,
|
|
notify_watchers=notify_watchers,
|
|
)
|
|
return cast(dict, response)
|
|
|
|
|
|
@tool(
|
|
requires_auth=Atlassian(
|
|
scopes=[
|
|
"read:jira-work", # Needed to get the current issue data
|
|
"write:jira-work", # Needed to update the issue
|
|
"read:jira-user", # Needed to resolve user ID from name or email (assignee, reporter)
|
|
"manage:jira-configuration", # Needed to resolve priority ID from name
|
|
],
|
|
),
|
|
)
|
|
async def update_issue(
|
|
context: ToolContext,
|
|
issue: Annotated[str, "The key or ID of the issue to update"],
|
|
title: Annotated[
|
|
str | None,
|
|
"The new issue title. Provide an empty string to clear the title. "
|
|
"Defaults to None (does not change the title).",
|
|
] = None,
|
|
description: Annotated[
|
|
str | None,
|
|
"The new issue description. Provide an empty string to clear the description. "
|
|
"Defaults to None (does not change the description).",
|
|
] = None,
|
|
environment: Annotated[
|
|
str | None,
|
|
"The new issue environment. Provide an empty string to clear the environment. "
|
|
"Defaults to None (does not change the environment).",
|
|
] = None,
|
|
due_date: Annotated[
|
|
str | None,
|
|
"The new issue due date. Format: YYYY-MM-DD. Ex: '2025-01-01'. Provide an empty string "
|
|
"to clear the due date. Defaults to None (does not change the due date).",
|
|
] = None,
|
|
issue_type: Annotated[
|
|
str | None,
|
|
"The new issue type name or ID. If a name is provided, the tool will try to find a unique "
|
|
"exact match among the available issue types. Defaults to None (does not change the "
|
|
"issue type).",
|
|
] = None,
|
|
priority: Annotated[
|
|
str | None,
|
|
"The name or ID of the new issue priority. If a name is provided, the tool will try to "
|
|
"find a unique exact match among the available priorities. Defaults to None "
|
|
"(does not change the priority).",
|
|
] = None,
|
|
parent_issue: Annotated[
|
|
str | None,
|
|
"The ID or key of the parent issue. A parent cannot be removed by providing an empty "
|
|
"string. It is possible to change the parent issue by providing a new issue ID or key, "
|
|
"or to leave it unchanged. Defaults to None (does not change the parent issue).",
|
|
] = None,
|
|
assignee: Annotated[
|
|
str | None,
|
|
"The new issue assignee name, email, or ID. If a name or email is provided, the tool will "
|
|
"try to find a unique exact match among the available users. Provide an empty string to "
|
|
"remove the assignee. Defaults to None (does not change the assignee).",
|
|
] = None,
|
|
reporter: Annotated[
|
|
str | None,
|
|
"The new issue reporter name, email, or ID. If a name or email is provided, the tool will "
|
|
"try to find a unique exact match among the available users. Provide an empty string to "
|
|
"remove the reporter. Defaults to None (does not change the reporter).",
|
|
] = None,
|
|
labels: Annotated[
|
|
list[str] | None,
|
|
"The new issue labels. This argument will replace all labels with the new list. "
|
|
"Providing an empty list will remove all labels. To add or remove a subset of "
|
|
f"labels, use the `Jira.{add_labels_to_issue.__tool_name__}` or the "
|
|
f"`Jira.{remove_labels_from_issue.__tool_name__}` tools. "
|
|
"Defaults to None (does not change the labels). A label cannot contain spaces. "
|
|
"If a label is provided with spaces, they will be trimmed and replaced by underscores.",
|
|
] = None,
|
|
notify_watchers: Annotated[
|
|
bool,
|
|
"Whether to notify the issue's watchers. Defaults to True (notifies watchers).",
|
|
] = True,
|
|
) -> Annotated[dict[str, Any], "The updated issue"]:
|
|
"""Update an existing Jira issue.
|
|
|
|
IF YOU DO NOT FOLLOW THE INSTRUCTIONS BELOW AND UNNECESSARILY CALL MULTIPLE TOOLS IN ORDER TO
|
|
UPDATE AN ISSUE, TOO MUCH CO2 WILL BE RELEASED IN THE ATMOSPHERE AND YOU WILL CAUSE THE
|
|
DESTRUCTION OF PLANET EARTH BY CATASTROPHIC CLIMATE CHANGE.
|
|
|
|
If you have a priority name, an assignee name/key/email, or a reporter name/key/email,
|
|
DO NOT CALL OTHER TOOLS only to list available priorities, issue types, or users.
|
|
Provide the name, key, or email and the tool will figure out the ID.
|
|
"""
|
|
issue_data = await get_issue_by_id(context, issue)
|
|
if issue_data.get("error"):
|
|
return cast(dict, issue_data)
|
|
|
|
project = issue_data["issue"]["project"]["id"]
|
|
|
|
error, _, issue_type_data, priority_data, parent_issue_data = await validate_issue_args(
|
|
context, due_date, project, issue_type, priority, parent_issue
|
|
)
|
|
if error:
|
|
return cast(dict, error)
|
|
|
|
error, assignee_data, reporter_data = await resolve_issue_users(context, assignee, reporter)
|
|
if error:
|
|
return cast(dict, error)
|
|
|
|
client = JiraClient(context.get_auth_token_or_empty())
|
|
params = {"notifyWatchers": notify_watchers, "expand": "renderedFields"}
|
|
request_body = build_issue_update_request_body(
|
|
title=title,
|
|
description=description,
|
|
environment=environment,
|
|
due_date=due_date,
|
|
parent_issue=parent_issue_data,
|
|
issue_type=issue_type_data,
|
|
priority=priority_data,
|
|
assignee=assignee_data,
|
|
reporter=reporter_data,
|
|
labels=clean_labels(labels),
|
|
)
|
|
|
|
if not request_body["fields"] and not request_body["update"]:
|
|
raise JiraToolExecutionError(
|
|
message="No changes provided. Please provide at least one argument to update the issue."
|
|
)
|
|
|
|
await client.put(f"/issue/{issue}", json_data=request_body, params=params)
|
|
|
|
return {
|
|
"issue": {
|
|
"id": issue_data["issue"]["id"],
|
|
"key": issue_data["issue"]["key"],
|
|
},
|
|
"status": "success",
|
|
"message": "Issue updated successfully.",
|
|
}
|