ForcePilot/backend/package/yuxi/channel/extensions/jira/agent_tools.py
Kris c5d8e3550e feat(channel): 添加 Jira 渠道扩展
新增 Jira 渠道扩展,支持在 Yuxi 平台中集成 Jira 项目管理渠道。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息与工单管理
- streaming: 流式消息处理
- parse: Jira 内容解析
- format: 消息格式转换
- agent_tools: Agent 工具集成
- security: 安全校验
- dedupe: 消息去重
- loopbreaker: 循环响应防护
- monitor: 渠道状态监控
- status: 会话与工单状态管理
- types: 类型定义
2026-05-21 11:10:54 +08:00

474 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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