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