ForcePilot/backend/package/yuxi/channel/extensions/jira/agent_tools.py

474 lines
20 KiB
Python
Raw Normal View History

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