新增 Jira 渠道扩展,支持在 Yuxi 平台中集成 Jira 项目管理渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息与工单管理 - streaming: 流式消息处理 - parse: Jira 内容解析 - format: 消息格式转换 - agent_tools: Agent 工具集成 - security: 安全校验 - dedupe: 消息去重 - loopbreaker: 循环响应防护 - monitor: 渠道状态监控 - status: 会话与工单状态管理 - types: 类型定义
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
|