474 lines
20 KiB
Python
474 lines
20 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import base64
|
|||
|
|
import logging
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
|
|||
|
|
from yuxi.channel.extensions.jira.format import markdown_to_adf
|
|||
|
|
from yuxi.channel.extensions.jira.parse import adf_to_plain_text
|
|||
|
|
from yuxi.channel.protocols import AgentTool, AgentToolParam
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class JiraAgentTools:
|
|||
|
|
def __init__(self):
|
|||
|
|
self._clients: dict[str, httpx.AsyncClient] = {}
|
|||
|
|
|
|||
|
|
def get_tools(self) -> list[AgentTool]:
|
|||
|
|
return [
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_search_issues",
|
|||
|
|
description="使用 JQL 搜索 Jira Issue,如 'project = SUPPORT AND status = Open'",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="jql", type="string", description="JQL 查询语句", required=True),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="max_results",
|
|||
|
|
type="integer",
|
|||
|
|
description="最大返回数量,默认 10",
|
|||
|
|
required=False,
|
|||
|
|
default=10,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_get_issue",
|
|||
|
|
description="获取指定 Issue 的详细信息,包括描述、状态、负责人、评论等",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="issue_key", type="string", description="Issue Key,如 SUPPORT-123", required=True
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_create_issue",
|
|||
|
|
description="在项目中创建新的 Issue",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="project_key", type="string", description="项目 Key,如 SUPPORT", required=True
|
|||
|
|
),
|
|||
|
|
AgentToolParam(name="summary", type="string", description="Issue 标题/摘要", required=True),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="description", type="string", description="Issue 描述 (Markdown 格式)", required=False
|
|||
|
|
),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="issue_type",
|
|||
|
|
type="string",
|
|||
|
|
description="Issue 类型,如 Bug/Task/Story,默认 Task",
|
|||
|
|
required=False,
|
|||
|
|
default="Task",
|
|||
|
|
),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="priority",
|
|||
|
|
type="string",
|
|||
|
|
description="优先级,如 High/Medium/Low,默认 Medium",
|
|||
|
|
required=False,
|
|||
|
|
default="Medium",
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_transition_issue",
|
|||
|
|
description="流转 Issue 的工作流状态,如 'In Progress' -> 'Done'",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="issue_key", type="string", description="Issue Key", required=True),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="transition_name",
|
|||
|
|
type="string",
|
|||
|
|
description="目标状态名称,如 'Done', 'In Progress'",
|
|||
|
|
required=True,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_get_transitions",
|
|||
|
|
description="获取 Issue 当前可用的工作流状态流转选项",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="issue_key", type="string", description="Issue Key", required=True),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_assign_issue",
|
|||
|
|
description="分配 Issue 给指定用户",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="issue_key", type="string", description="Issue Key", required=True),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="account_id", type="string", description="目标用户的 Atlassian Account ID", required=True
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_update_issue",
|
|||
|
|
description="更新 Issue 的字段,如摘要、描述、优先级等",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="issue_key", type="string", description="Issue Key", required=True),
|
|||
|
|
AgentToolParam(name="summary", type="string", description="新的 Issue 标题", required=False),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="description", type="string", description="新的 Issue 描述 (Markdown 格式)", required=False
|
|||
|
|
),
|
|||
|
|
AgentToolParam(name="priority", type="string", description="新的优先级", required=False),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_get_comments",
|
|||
|
|
description="获取 Issue 的评论列表",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="issue_key", type="string", description="Issue Key", required=True),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="max_results",
|
|||
|
|
type="integer",
|
|||
|
|
description="最大返回数量,默认 20",
|
|||
|
|
required=False,
|
|||
|
|
default=20,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_search_users",
|
|||
|
|
description="搜索 Jira 用户,用于 @mention 或分配 Issue",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="query", type="string", description="用户名或邮箱搜索关键词", required=True),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_get_projects",
|
|||
|
|
description="获取可访问的所有 Jira 项目列表",
|
|||
|
|
parameters=[],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_delete_issue",
|
|||
|
|
description="删除指定的 Jira Issue",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="issue_key", type="string", description="Issue Key,如 SUPPORT-123", required=True
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_get_changelog",
|
|||
|
|
description="获取 Issue 的变更历史记录",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="issue_key", type="string", description="Issue Key", required=True),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="max_results",
|
|||
|
|
type="integer",
|
|||
|
|
description="最大返回数量,默认 20",
|
|||
|
|
required=False,
|
|||
|
|
default=20,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
AgentTool(
|
|||
|
|
name="jira_add_worklog",
|
|||
|
|
description="为 Issue 添加工作日志(工时记录)",
|
|||
|
|
parameters=[
|
|||
|
|
AgentToolParam(name="issue_key", type="string", description="Issue Key", required=True),
|
|||
|
|
AgentToolParam(
|
|||
|
|
name="time_spent", type="string", description="耗时,如 '1h 30m' 或 '2d'", required=True
|
|||
|
|
),
|
|||
|
|
AgentToolParam(name="comment", type="string", description="工作日志说明", required=False),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
async def execute(self, tool_name: str, params: dict, context: dict) -> dict:
|
|||
|
|
account = context.get("account", {})
|
|||
|
|
if not account or not account.get("site_url"):
|
|||
|
|
return {"success": False, "error": "Jira 账户未配置"}
|
|||
|
|
|
|||
|
|
client = await self._get_client(account)
|
|||
|
|
|
|||
|
|
handlers = {
|
|||
|
|
"jira_search_issues": self._search_issues,
|
|||
|
|
"jira_get_issue": self._get_issue,
|
|||
|
|
"jira_create_issue": self._create_issue,
|
|||
|
|
"jira_transition_issue": self._transition_issue,
|
|||
|
|
"jira_get_transitions": self._get_transitions,
|
|||
|
|
"jira_assign_issue": self._assign_issue,
|
|||
|
|
"jira_update_issue": self._update_issue,
|
|||
|
|
"jira_get_comments": self._get_comments,
|
|||
|
|
"jira_search_users": self._search_users,
|
|||
|
|
"jira_get_projects": self._get_projects,
|
|||
|
|
"jira_delete_issue": self._delete_issue,
|
|||
|
|
"jira_get_changelog": self._get_changelog,
|
|||
|
|
"jira_add_worklog": self._add_worklog,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handler = handlers.get(tool_name)
|
|||
|
|
if not handler:
|
|||
|
|
return {"success": False, "error": f"Unknown tool: {tool_name}"}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = await handler(client, params)
|
|||
|
|
return {"success": True, "result": result}
|
|||
|
|
except httpx.HTTPStatusError as e:
|
|||
|
|
logger.exception("jira tool %s HTTP error", tool_name)
|
|||
|
|
body = e.response.json() if e.response.content else {}
|
|||
|
|
msg = body.get("errorMessages", [str(e)])[0] if isinstance(body, dict) else str(e)
|
|||
|
|
return {"success": False, "error": msg}
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.exception("jira tool %s failed", tool_name)
|
|||
|
|
return {"success": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
async def _search_issues(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
jql = params["jql"]
|
|||
|
|
max_results = min(params.get("max_results", 10), 50)
|
|||
|
|
resp = await client.post(
|
|||
|
|
"/rest/api/3/search",
|
|||
|
|
json={
|
|||
|
|
"jql": jql,
|
|||
|
|
"maxResults": max_results,
|
|||
|
|
"fields": ["summary", "status", "priority", "assignee", "issuetype", "created"],
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
return {
|
|||
|
|
"total": data.get("total", 0),
|
|||
|
|
"issues": [
|
|||
|
|
{
|
|||
|
|
"key": i.get("key"),
|
|||
|
|
"summary": i.get("fields", {}).get("summary"),
|
|||
|
|
"status": i.get("fields", {}).get("status", {}).get("name"),
|
|||
|
|
"priority": i.get("fields", {}).get("priority", {}).get("name"),
|
|||
|
|
"assignee": i.get("fields", {}).get("assignee", {}).get("displayName"),
|
|||
|
|
"issue_type": i.get("fields", {}).get("issuetype", {}).get("name"),
|
|||
|
|
}
|
|||
|
|
for i in data.get("issues", [])
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _get_issue(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
fields = "summary,description,status,priority,assignee,reporter,issuetype,created,updated,comment,labels"
|
|||
|
|
resp = await client.get(
|
|||
|
|
f"/rest/api/3/issue/{issue_key}",
|
|||
|
|
params={"fields": fields},
|
|||
|
|
)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
fields = data.get("fields", {})
|
|||
|
|
return {
|
|||
|
|
"key": data.get("key"),
|
|||
|
|
"summary": fields.get("summary"),
|
|||
|
|
"description": _adf_to_summary(fields.get("description")),
|
|||
|
|
"status": fields.get("status", {}).get("name"),
|
|||
|
|
"priority": fields.get("priority", {}).get("name"),
|
|||
|
|
"assignee": fields.get("assignee", {}).get("displayName"),
|
|||
|
|
"reporter": fields.get("reporter", {}).get("displayName"),
|
|||
|
|
"issue_type": fields.get("issuetype", {}).get("name"),
|
|||
|
|
"created": fields.get("created"),
|
|||
|
|
"updated": fields.get("updated"),
|
|||
|
|
"labels": fields.get("labels", []),
|
|||
|
|
"comments_count": len(fields.get("comment", {}).get("comments", [])),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _create_issue(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
fields: dict[str, Any] = {
|
|||
|
|
"project": {"key": params["project_key"]},
|
|||
|
|
"summary": params["summary"],
|
|||
|
|
"issuetype": {"name": params.get("issue_type", "Task")},
|
|||
|
|
}
|
|||
|
|
if params.get("description"):
|
|||
|
|
fields["description"] = markdown_to_adf(params["description"])
|
|||
|
|
if params.get("priority"):
|
|||
|
|
fields["priority"] = {"name": params["priority"]}
|
|||
|
|
|
|||
|
|
resp = await client.post("/rest/api/3/issue", json={"fields": fields})
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
return {"key": data.get("key"), "id": data.get("id"), "url": data.get("self")}
|
|||
|
|
|
|||
|
|
async def _transition_issue(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
transition_name = params["transition_name"].lower()
|
|||
|
|
|
|||
|
|
trans_resp = await client.get(f"/rest/api/3/issue/{issue_key}/transitions")
|
|||
|
|
trans_resp.raise_for_status()
|
|||
|
|
transitions = trans_resp.json().get("transitions", [])
|
|||
|
|
|
|||
|
|
transition_id = None
|
|||
|
|
for t in transitions:
|
|||
|
|
if (
|
|||
|
|
t.get("name", "").lower() == transition_name
|
|||
|
|
or t.get("to", {}).get("name", "").lower() == transition_name
|
|||
|
|
):
|
|||
|
|
transition_id = t["id"]
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if not transition_id:
|
|||
|
|
available = [t.get("name") for t in transitions]
|
|||
|
|
return {
|
|||
|
|
"success": False,
|
|||
|
|
"error": f"Transition '{params['transition_name']}' not found. Available: {available}",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp = await client.post(
|
|||
|
|
f"/rest/api/3/issue/{issue_key}/transitions", json={"transition": {"id": transition_id}}
|
|||
|
|
)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
return {"transitioned": True, "issue_key": issue_key}
|
|||
|
|
|
|||
|
|
async def _get_transitions(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
resp = await client.get(f"/rest/api/3/issue/{issue_key}/transitions")
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
transitions = resp.json().get("transitions", [])
|
|||
|
|
return {
|
|||
|
|
"transitions": [
|
|||
|
|
{"id": t["id"], "name": t["name"], "to_status": t.get("to", {}).get("name")} for t in transitions
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _assign_issue(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
account_id = params["account_id"]
|
|||
|
|
resp = await client.put(f"/rest/api/3/issue/{issue_key}/assignee", json={"accountId": account_id})
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
return {"assigned": True, "issue_key": issue_key, "account_id": account_id}
|
|||
|
|
|
|||
|
|
async def _update_issue(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
fields: dict[str, Any] = {}
|
|||
|
|
if params.get("summary"):
|
|||
|
|
fields["summary"] = params["summary"]
|
|||
|
|
if params.get("description"):
|
|||
|
|
fields["description"] = markdown_to_adf(params["description"])
|
|||
|
|
if params.get("priority"):
|
|||
|
|
fields["priority"] = {"name": params["priority"]}
|
|||
|
|
|
|||
|
|
if not fields:
|
|||
|
|
return {"success": False, "error": "No fields to update"}
|
|||
|
|
|
|||
|
|
resp = await client.put(f"/rest/api/3/issue/{issue_key}", json={"fields": fields})
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
return {"updated": True, "issue_key": issue_key}
|
|||
|
|
|
|||
|
|
async def _get_comments(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
max_results = min(params.get("max_results", 20), 100)
|
|||
|
|
resp = await client.get(f"/rest/api/3/issue/{issue_key}/comment", params={"maxResults": max_results})
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
return {
|
|||
|
|
"total": data.get("total", 0),
|
|||
|
|
"comments": [
|
|||
|
|
{
|
|||
|
|
"id": c.get("id"),
|
|||
|
|
"author": c.get("author", {}).get("displayName"),
|
|||
|
|
"body": _adf_to_summary(c.get("body")),
|
|||
|
|
"created": c.get("created"),
|
|||
|
|
}
|
|||
|
|
for c in data.get("comments", [])
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _search_users(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
query = params["query"]
|
|||
|
|
resp = await client.get("/rest/api/3/user/search", params={"query": query, "maxResults": 20})
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
users = resp.json() if isinstance(resp.json(), list) else resp.json().get("values", [])
|
|||
|
|
return {
|
|||
|
|
"users": [
|
|||
|
|
{
|
|||
|
|
"account_id": u.get("accountId"),
|
|||
|
|
"display_name": u.get("displayName"),
|
|||
|
|
"email": u.get("emailAddress"),
|
|||
|
|
"active": u.get("active"),
|
|||
|
|
}
|
|||
|
|
for u in users
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _get_projects(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
resp = await client.get("/rest/api/3/project")
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
projects = resp.json()
|
|||
|
|
return {
|
|||
|
|
"projects": [
|
|||
|
|
{
|
|||
|
|
"key": p.get("key"),
|
|||
|
|
"name": p.get("name"),
|
|||
|
|
"project_type": p.get("projectTypeKey"),
|
|||
|
|
}
|
|||
|
|
for p in projects
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _delete_issue(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
resp = await client.delete(f"/rest/api/3/issue/{issue_key}")
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
return {"deleted": True, "issue_key": issue_key}
|
|||
|
|
|
|||
|
|
async def _get_changelog(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
max_results = min(params.get("max_results", 20), 100)
|
|||
|
|
resp = await client.get(
|
|||
|
|
f"/rest/api/3/issue/{issue_key}/changelog",
|
|||
|
|
params={"maxResults": max_results},
|
|||
|
|
)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
return {
|
|||
|
|
"total": data.get("total", 0),
|
|||
|
|
"histories": [
|
|||
|
|
{
|
|||
|
|
"id": h.get("id"),
|
|||
|
|
"author": h.get("author", {}).get("displayName"),
|
|||
|
|
"created": h.get("created"),
|
|||
|
|
"items": [
|
|||
|
|
{
|
|||
|
|
"field": item.get("field"),
|
|||
|
|
"from": item.get("fromString"),
|
|||
|
|
"to": item.get("toString"),
|
|||
|
|
}
|
|||
|
|
for item in h.get("items", [])
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
for h in data.get("values", [])
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _add_worklog(self, client: httpx.AsyncClient, params: dict) -> dict:
|
|||
|
|
issue_key = params["issue_key"]
|
|||
|
|
body: dict[str, Any] = {"timeSpent": params["time_spent"]}
|
|||
|
|
if params.get("comment"):
|
|||
|
|
body["comment"] = markdown_to_adf(params["comment"])
|
|||
|
|
resp = await client.post(f"/rest/api/3/issue/{issue_key}/worklog", json=body)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
data = resp.json()
|
|||
|
|
return {
|
|||
|
|
"worklog_id": data.get("id"),
|
|||
|
|
"issue_key": issue_key,
|
|||
|
|
"time_spent": params["time_spent"],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def _get_client(self, account: dict) -> httpx.AsyncClient:
|
|||
|
|
account_key = account.get("account_id", "default")
|
|||
|
|
if account_key not in self._clients:
|
|||
|
|
credentials = f"{account['email']}:{account['api_token']}"
|
|||
|
|
auth = f"Basic {base64.b64encode(credentials.encode('utf-8')).decode('utf-8')}"
|
|||
|
|
self._clients[account_key] = httpx.AsyncClient(
|
|||
|
|
base_url=account["site_url"].rstrip("/"),
|
|||
|
|
headers={"Authorization": auth, "Accept": "application/json"},
|
|||
|
|
timeout=httpx.Timeout(30.0),
|
|||
|
|
)
|
|||
|
|
return self._clients[account_key]
|
|||
|
|
|
|||
|
|
async def close(self):
|
|||
|
|
for client in self._clients.values():
|
|||
|
|
await client.aclose()
|
|||
|
|
self._clients.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _adf_to_summary(adf: dict | None, max_len: int = 500) -> str:
|
|||
|
|
if not adf or not isinstance(adf, dict):
|
|||
|
|
return ""
|
|||
|
|
text = adf_to_plain_text(adf)
|
|||
|
|
if len(text) > max_len:
|
|||
|
|
return text[:max_len] + "..."
|
|||
|
|
return text
|