880 lines
33 KiB
Python
880 lines
33 KiB
Python
|
|
"""Jira / Confluence discover / preview_tools / create_tools handler 实现 +
|
|||
|
|
元数据发现 + JQL/CQL 渲染与白名单校验 + cloudId 解析 + 错误提取器。
|
|||
|
|
|
|||
|
|
ADF 序列化与字段类型映射已移至 ``generators.py``(避免循环导入)。
|
|||
|
|
|
|||
|
|
handler 签名遵循 ``OperationHandler`` 协议:
|
|||
|
|
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。
|
|||
|
|
|
|||
|
|
handler 内部职责(见设计文档 §4.6 / §6):
|
|||
|
|
1. 从 ``system_config`` 提取 ``connection_config`` / ``auth_config`` / ``_resolved_token``
|
|||
|
|
2. 根据 ``deployment_mode`` + ``auth_type`` 解析 base_url(Cloud 3LO 需 cloudId 拼接 OAuth 代理地址)
|
|||
|
|
3. 构建 ``auth_headers``(token 由 use_cases 层注入到 ``system_config["_resolved_token"]``,
|
|||
|
|
非 token 类认证从 ``auth_config`` 构建)
|
|||
|
|
4. 构造带 429 退避的 ``_request_with_retry``,发起元数据发现请求
|
|||
|
|
5. 调用 ``JiraConfigGenerator`` / ``ConfluenceConfigGenerator`` 生成工具配置草稿
|
|||
|
|
|
|||
|
|
两层错误处理(见设计文档 §8):
|
|||
|
|
|
|||
|
|
- **L1**: ``extract_jira_error`` / ``extract_confluence_error`` — 注册到
|
|||
|
|
``http_error_extractor_registry``,由框架 executor 在运行时工具执行失败时调用。
|
|||
|
|
- **L2**: ``_map_atlassian_error`` — 不注册到任何注册表,仅在 handler 内部
|
|||
|
|
discover/preview/create 阶段失败后调用,将 HTTP 状态码映射为
|
|||
|
|
``ExternalSystemError`` 子类。
|
|||
|
|
|
|||
|
|
429 退避分层(见设计文档 §4.6):
|
|||
|
|
- discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现
|
|||
|
|
- 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429]`` 配置
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import base64
|
|||
|
|
import re
|
|||
|
|
from typing import TYPE_CHECKING, Any
|
|||
|
|
|
|||
|
|
from yuxi.external_systems.exceptions import (
|
|||
|
|
AccessDeniedError,
|
|||
|
|
AuthError,
|
|||
|
|
DomainValidationError,
|
|||
|
|
EntityNotFoundError,
|
|||
|
|
ExecutionError,
|
|||
|
|
RateLimitExceededError,
|
|||
|
|
)
|
|||
|
|
from yuxi.external_systems.integrations.jira.generators import (
|
|||
|
|
ConfluenceConfigGenerator,
|
|||
|
|
JiraConfigGenerator,
|
|||
|
|
)
|
|||
|
|
from yuxi.external_systems.integrations.jira.metadata import (
|
|||
|
|
CLOUD_OAUTH_PROXY_BASE,
|
|||
|
|
CONFLUENCE_CLOUD_SPACES_PATH,
|
|||
|
|
CONFLUENCE_DC_SPACES_PATH,
|
|||
|
|
JIRA_FIELDS_PATH,
|
|||
|
|
JIRA_ISSUE_TYPES_PATH,
|
|||
|
|
JIRA_PROJECTS_PATH,
|
|||
|
|
JIRA_STATUSES_PATH,
|
|||
|
|
)
|
|||
|
|
from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft
|
|||
|
|
|
|||
|
|
if TYPE_CHECKING:
|
|||
|
|
import httpx
|
|||
|
|
|
|||
|
|
# discover/preview/create 阶段 429 退避参数
|
|||
|
|
_RETRY_MAX_ATTEMPTS = 5
|
|||
|
|
_RETRY_BASE_DELAY = 1.0
|
|||
|
|
|
|||
|
|
# Atlassian 通用请求头(除 Authorization 外)
|
|||
|
|
_ATLASSIAN_HEADERS: dict[str, str] = {
|
|||
|
|
"Accept": "application/json",
|
|||
|
|
"Content-Type": "application/json; charset=utf-8",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# 错误提取器(L1)— 注册到 http_error_extractor_registry
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
|
|||
|
|
def extract_jira_error(response: httpx.Response) -> str | None:
|
|||
|
|
"""从 Jira 错误响应中提取友好错误信息(L1)。
|
|||
|
|
|
|||
|
|
Jira 错误响应含 ``errorMessages``(字符串数组)与 ``errors``(字段级错误字典)::
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
"errorMessages": ["Issue does not exist or you do not have permission to see it."],
|
|||
|
|
"errors": {"summary": "Field 'summary' cannot be empty."}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
拼接后的错误消息字符串,非 Jira 错误格式时返回 None。
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
body = response.json()
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if not isinstance(body, dict):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
parts: list[str] = []
|
|||
|
|
|
|||
|
|
error_messages = body.get("errorMessages") or []
|
|||
|
|
for msg in error_messages:
|
|||
|
|
if isinstance(msg, str):
|
|||
|
|
parts.append(msg)
|
|||
|
|
|
|||
|
|
errors = body.get("errors") or {}
|
|||
|
|
if isinstance(errors, dict):
|
|||
|
|
for field, message in errors.items():
|
|||
|
|
parts.append(f"[{field}] {message}")
|
|||
|
|
|
|||
|
|
return "; ".join(parts) if parts else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def extract_confluence_error(response: httpx.Response) -> str | None:
|
|||
|
|
"""从 Confluence 错误响应中提取友好错误信息(L1)。
|
|||
|
|
|
|||
|
|
Confluence 错误响应格式与 Jira 类似,但字段级错误可能嵌套在 ``data.errors`` 中::
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
"message": "Content not found",
|
|||
|
|
"data": {"errors": {"title": "Title is required."}}
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
body = response.json()
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if not isinstance(body, dict):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
parts: list[str] = []
|
|||
|
|
|
|||
|
|
message = body.get("message")
|
|||
|
|
if isinstance(message, str) and message:
|
|||
|
|
parts.append(message)
|
|||
|
|
|
|||
|
|
data = body.get("data") or {}
|
|||
|
|
if isinstance(data, dict):
|
|||
|
|
errors = data.get("errors") or {}
|
|||
|
|
if isinstance(errors, dict):
|
|||
|
|
for field, err_msg in errors.items():
|
|||
|
|
parts.append(f"[{field}] {err_msg}")
|
|||
|
|
|
|||
|
|
# Confluence 也可能用 errorMessages 格式
|
|||
|
|
error_messages = body.get("errorMessages") or []
|
|||
|
|
for msg in error_messages:
|
|||
|
|
if isinstance(msg, str):
|
|||
|
|
parts.append(msg)
|
|||
|
|
|
|||
|
|
return "; ".join(parts) if parts else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _map_atlassian_error(
|
|||
|
|
status_code: int,
|
|||
|
|
error_body: dict[str, Any],
|
|||
|
|
*,
|
|||
|
|
vendor: str = "Atlassian",
|
|||
|
|
) -> Exception:
|
|||
|
|
"""将 Atlassian 错误响应转换为 ExternalSystemError 子类(L2)。
|
|||
|
|
|
|||
|
|
仅在 handler 内部(discover/preview/create 阶段)使用,不注册到框架。
|
|||
|
|
"""
|
|||
|
|
# 提取错误消息(兼容 Jira / Confluence 两种格式)
|
|||
|
|
parts: list[str] = []
|
|||
|
|
if isinstance(error_body, dict):
|
|||
|
|
for msg in error_body.get("errorMessages") or []:
|
|||
|
|
if isinstance(msg, str):
|
|||
|
|
parts.append(msg)
|
|||
|
|
message = error_body.get("message")
|
|||
|
|
if isinstance(message, str) and message:
|
|||
|
|
parts.append(message)
|
|||
|
|
errors = error_body.get("errors") or {}
|
|||
|
|
if isinstance(errors, dict):
|
|||
|
|
for field, err_msg in errors.items():
|
|||
|
|
parts.append(f"[{field}] {err_msg}")
|
|||
|
|
data = error_body.get("data") or {}
|
|||
|
|
if isinstance(data, dict):
|
|||
|
|
data_errors = data.get("errors") or {}
|
|||
|
|
if isinstance(data_errors, dict):
|
|||
|
|
for field, err_msg in data_errors.items():
|
|||
|
|
parts.append(f"[{field}] {err_msg}")
|
|||
|
|
|
|||
|
|
full_message = "; ".join(parts) if parts else f"{vendor} 未知错误 (status={status_code})"
|
|||
|
|
|
|||
|
|
if status_code == 401:
|
|||
|
|
return AuthError(f"{vendor} 认证失败: {full_message}")
|
|||
|
|
if status_code == 403:
|
|||
|
|
return AccessDeniedError(f"{vendor} 访问被拒: {full_message}")
|
|||
|
|
if status_code == 404:
|
|||
|
|
return EntityNotFoundError(f"{vendor} 资源不存在: {full_message}")
|
|||
|
|
if status_code == 429:
|
|||
|
|
return RateLimitExceededError(
|
|||
|
|
f"{vendor} 限流: {full_message}",
|
|||
|
|
retry_after=60,
|
|||
|
|
limit_type="qps",
|
|||
|
|
)
|
|||
|
|
if 500 <= status_code < 600:
|
|||
|
|
return ExecutionError(f"{vendor} 服务端错误: {full_message} (status={status_code})")
|
|||
|
|
return ExecutionError(f"{vendor} 错误: {full_message} (status={status_code})")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# JQL 渲染与白名单校验(见设计文档 §5.4)
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
# JQL 字段白名单(全局默认,项目级可由 createmeta 返回的字段 id 覆盖)
|
|||
|
|
JQL_ALLOWED_FIELDS: set[str] = {
|
|||
|
|
"project",
|
|||
|
|
"issuetype",
|
|||
|
|
"status",
|
|||
|
|
"priority",
|
|||
|
|
"assignee",
|
|||
|
|
"reporter",
|
|||
|
|
"created",
|
|||
|
|
"updated",
|
|||
|
|
"due",
|
|||
|
|
"summary",
|
|||
|
|
"description",
|
|||
|
|
"labels",
|
|||
|
|
"components",
|
|||
|
|
"versions",
|
|||
|
|
"fixVersion",
|
|||
|
|
"resolution",
|
|||
|
|
"resolutiondate",
|
|||
|
|
"watches",
|
|||
|
|
"votes",
|
|||
|
|
"attachment",
|
|||
|
|
"subtasks",
|
|||
|
|
"issuelinks",
|
|||
|
|
"lastViewed",
|
|||
|
|
"environment",
|
|||
|
|
"workratio",
|
|||
|
|
"statuscategory",
|
|||
|
|
# 常见 customfield 简写
|
|||
|
|
"cf[10001]",
|
|||
|
|
"cf[10002]",
|
|||
|
|
"cf[10004]",
|
|||
|
|
"cf[10005]",
|
|||
|
|
"cf[10008]",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 多词运算符需作为整体匹配(IS NOT / WAS IN / NOT IN 等)
|
|||
|
|
JQL_ALLOWED_OPERATORS: set[str] = {
|
|||
|
|
"=",
|
|||
|
|
"!=",
|
|||
|
|
"~",
|
|||
|
|
"!~",
|
|||
|
|
">",
|
|||
|
|
">=",
|
|||
|
|
"<",
|
|||
|
|
"<=",
|
|||
|
|
"IN",
|
|||
|
|
"NOT IN",
|
|||
|
|
"IS",
|
|||
|
|
"IS NOT",
|
|||
|
|
"WAS",
|
|||
|
|
"WAS IN",
|
|||
|
|
"WAS NOT",
|
|||
|
|
"WAS NOT IN",
|
|||
|
|
"CHANGED",
|
|||
|
|
"AND",
|
|||
|
|
"OR",
|
|||
|
|
"NOT",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 禁止的 SQL 关键字(防御性拦截 SQL 注入)
|
|||
|
|
JQL_FORBIDDEN_KEYWORDS: set[str] = {
|
|||
|
|
"DROP",
|
|||
|
|
"DELETE",
|
|||
|
|
"UPDATE",
|
|||
|
|
"INSERT",
|
|||
|
|
"EXEC",
|
|||
|
|
"MERGE",
|
|||
|
|
"ALTER",
|
|||
|
|
"CREATE",
|
|||
|
|
"TRUNCATE",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# JQL 内置函数名白名单(不作为字段名校验)
|
|||
|
|
_JQL_FUNCTION_NAMES: set[str] = {
|
|||
|
|
"currentlogin",
|
|||
|
|
"now",
|
|||
|
|
"startofday",
|
|||
|
|
"endofday",
|
|||
|
|
"startofweek",
|
|||
|
|
"endofweek",
|
|||
|
|
"startofmonth",
|
|||
|
|
"endofmonth",
|
|||
|
|
"startofyear",
|
|||
|
|
"endofyear",
|
|||
|
|
"currentuser",
|
|||
|
|
"membersof",
|
|||
|
|
"approvals",
|
|||
|
|
"allmoodified",
|
|||
|
|
"releasedversions",
|
|||
|
|
"unreleasedversions",
|
|||
|
|
"standardissuetypes",
|
|||
|
|
"subtaskissueypes",
|
|||
|
|
"cascadeoption",
|
|||
|
|
"myapproval",
|
|||
|
|
"mypending",
|
|||
|
|
"currentestimate",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 多词运算符正则(按长度降序匹配,避免 NOT 被 NOT IN 截断)
|
|||
|
|
_MULTI_WORD_OPERATOR_RE = re.compile(
|
|||
|
|
r"\b(?:NOT\s+IN|IS\s+NOT|WAS\s+IN|WAS\s+NOT\s+IN|WAS\s+NOT)\b",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
# 字段 token 正则:word 或 cf[数字]
|
|||
|
|
_FIELD_TOKEN_RE = re.compile(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b|cf\[\d+\]")
|
|||
|
|
# 字面量正则:双引号或单引号包裹的内容
|
|||
|
|
_LITERAL_RE = re.compile(r"""["'][^"']*["']""")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_jql(
|
|||
|
|
project_key: str,
|
|||
|
|
issue_type_name: str | None = None,
|
|||
|
|
extra_conditions: list[str] | None = None,
|
|||
|
|
) -> str:
|
|||
|
|
"""渲染 JQL,project 与 issue_type 固定,extra_conditions 必须来自白名单字段。"""
|
|||
|
|
parts = [f'project = "{project_key}"']
|
|||
|
|
if issue_type_name:
|
|||
|
|
parts.append(f'issuetype = "{issue_type_name}"')
|
|||
|
|
if extra_conditions:
|
|||
|
|
parts.extend(extra_conditions)
|
|||
|
|
return " AND ".join(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _validate_jql(jql: str, field_whitelist: set[str] | None = None) -> str:
|
|||
|
|
"""校验 JQL 字段与运算符是否在白名单中,禁止危险关键字。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
jql: 待校验的 JQL 语句。
|
|||
|
|
field_whitelist: 项目级字段白名单(含 ``customfield_*``),未提供时使用全局白名单。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
校验通过的 JQL(原样返回)。
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
DomainValidationError: 字段或运算符不在白名单,或包含危险关键字。
|
|||
|
|
"""
|
|||
|
|
upper_jql = jql.upper()
|
|||
|
|
for kw in JQL_FORBIDDEN_KEYWORDS:
|
|||
|
|
if kw in upper_jql:
|
|||
|
|
raise DomainValidationError(f"JQL 包含禁止关键字: {kw}(疑似 SQL 注入)")
|
|||
|
|
|
|||
|
|
whitelist = field_whitelist or JQL_ALLOWED_FIELDS
|
|||
|
|
whitelist_lower = {w.lower() for w in whitelist}
|
|||
|
|
|
|||
|
|
# 1. 先移除字面量(双引号/单引号包裹的内容),避免字面量中的字母被误提取为字段名
|
|||
|
|
jql_without_literals = _LITERAL_RE.sub("", jql)
|
|||
|
|
|
|||
|
|
# 2. 移除多词运算符(避免 NOT IN 被拆分为 NOT + IN 后 NOT 被当作字段名校验)
|
|||
|
|
jql_without_multi_word_ops = _MULTI_WORD_OPERATOR_RE.sub("", jql_without_literals)
|
|||
|
|
|
|||
|
|
# 3. 提取剩余的 word token 与 cf[数字] token
|
|||
|
|
tokens = _FIELD_TOKEN_RE.findall(jql_without_multi_word_ops)
|
|||
|
|
for token in tokens:
|
|||
|
|
# 跳过单词运算符(AND / OR / NOT / IS / WAS / IN / CHANGED 等)
|
|||
|
|
if token.upper() in JQL_ALLOWED_OPERATORS:
|
|||
|
|
continue
|
|||
|
|
# 跳过 JQL 函数名(如 currentLogin() / now() / startOfDay() 等)
|
|||
|
|
if token.lower() in _JQL_FUNCTION_NAMES:
|
|||
|
|
continue
|
|||
|
|
# 校验字段名
|
|||
|
|
if token.lower() not in whitelist_lower:
|
|||
|
|
raise DomainValidationError(f"JQL 字段不在白名单中: {token}(项目字段元数据未返回)")
|
|||
|
|
return jql
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# system_config 解析辅助
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_deployment_mode(system_config: dict[str, Any]) -> str:
|
|||
|
|
"""从 system_config 提取 deployment_mode,默认 cloud。"""
|
|||
|
|
connection_config = system_config.get("connection_config") or {}
|
|||
|
|
return str(connection_config.get("deployment_mode", "cloud"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_api_version(system_config: dict[str, Any], deployment_mode: str) -> str:
|
|||
|
|
"""从 system_config 提取 api_version,未配置时按部署模式推断(cloud=v3, data_center=v2)。"""
|
|||
|
|
connection_config = system_config.get("connection_config") or {}
|
|||
|
|
api_version = connection_config.get("api_version")
|
|||
|
|
if api_version:
|
|||
|
|
return str(api_version)
|
|||
|
|
return "v3" if deployment_mode == "cloud" else "v2"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_auth_type(system_config: dict[str, Any]) -> str:
|
|||
|
|
"""从 system_config 提取 auth_type。"""
|
|||
|
|
return str(system_config.get("auth_type", "jira_cloud_3lo"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
|
|||
|
|
"""从 system_config 提取认证信息,构建 Atlassian 请求头。
|
|||
|
|
|
|||
|
|
支持的认证方式:
|
|||
|
|
- token 类(jira_cloud_3lo / oauth2_authorization_code):从 ``_resolved_token.access_token`` 取 Bearer token
|
|||
|
|
- basic(Cloud API token):从 ``auth_config`` 取 username/password 构建 Basic Auth
|
|||
|
|
- jira_dc_pat(DC PAT):从 ``auth_config`` 取 token 构建 Bearer Auth
|
|||
|
|
"""
|
|||
|
|
auth_type = _resolve_auth_type(system_config)
|
|||
|
|
headers = dict(_ATLASSIAN_HEADERS)
|
|||
|
|
|
|||
|
|
if auth_type in ("jira_cloud_3lo", "oauth2_authorization_code"):
|
|||
|
|
token_info = system_config.get("_resolved_token")
|
|||
|
|
if not isinstance(token_info, dict) or not token_info.get("access_token"):
|
|||
|
|
raise AuthError(
|
|||
|
|
f"jira: system_config 缺少 _resolved_token,use_cases 层未注入 token(auth_type={auth_type})"
|
|||
|
|
)
|
|||
|
|
access_token = token_info["access_token"]
|
|||
|
|
headers["Authorization"] = f"Bearer {access_token}"
|
|||
|
|
return headers
|
|||
|
|
|
|||
|
|
auth_config = system_config.get("auth_config") or {}
|
|||
|
|
|
|||
|
|
if auth_type == "basic":
|
|||
|
|
username = auth_config.get("username")
|
|||
|
|
password = auth_config.get("password")
|
|||
|
|
if not username or not password:
|
|||
|
|
raise AuthError("jira basic 认证缺少 username / password(email + API token)")
|
|||
|
|
credentials = f"{username}:{password}"
|
|||
|
|
encoded = base64.b64encode(credentials.encode("utf-8")).decode("ascii")
|
|||
|
|
headers["Authorization"] = f"Basic {encoded}"
|
|||
|
|
return headers
|
|||
|
|
|
|||
|
|
if auth_type == "jira_dc_pat":
|
|||
|
|
token = auth_config.get("token")
|
|||
|
|
if not token:
|
|||
|
|
raise AuthError("jira_dc_pat 认证缺少 token")
|
|||
|
|
headers["Authorization"] = f"Bearer {token}"
|
|||
|
|
return headers
|
|||
|
|
|
|||
|
|
raise AuthError(f"jira 不支持的认证类型: {auth_type}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_base_url(system_config: dict[str, Any], deployment_mode: str) -> str:
|
|||
|
|
"""根据部署模式与认证方式解析 API base_url(见设计文档 §6.3)。
|
|||
|
|
|
|||
|
|
- Cloud 3LO: ``https://api.atlassian.com/ex/{product}/{cloudId}``(cloudId 从 ``_resolved_token.extra.cloud_id`` 读取)
|
|||
|
|
- Cloud API token: ``https://{site}.atlassian.net``(从 ``connection_config.site_url`` 读取)
|
|||
|
|
- DC OAuth2 / PAT: ``https://{host}:{port}/[context]``(从 ``connection_config.base_url`` 读取)
|
|||
|
|
|
|||
|
|
多站点场景:若 ``connection_config.cloud_id`` 显式配置,校验与 ``extra.cloud_id`` 是否一致。
|
|||
|
|
"""
|
|||
|
|
connection_config = system_config.get("connection_config") or {}
|
|||
|
|
|
|||
|
|
if deployment_mode != "cloud":
|
|||
|
|
base_url = connection_config.get("base_url")
|
|||
|
|
if not base_url:
|
|||
|
|
raise DomainValidationError("Jira DC 缺少 connection_config.base_url")
|
|||
|
|
return str(base_url).rstrip("/")
|
|||
|
|
|
|||
|
|
# Cloud: 优先使用 _resolved_token.extra.cloud_id 拼接 OAuth 代理地址
|
|||
|
|
token_info = system_config.get("_resolved_token") or {}
|
|||
|
|
extra = token_info.get("extra") or {}
|
|||
|
|
cloud_id_from_token = extra.get("cloud_id")
|
|||
|
|
|
|||
|
|
# connection_config.cloud_id 为用户显式配置的期望 cloudId(多站点场景)
|
|||
|
|
expected_cloud_id = connection_config.get("cloud_id")
|
|||
|
|
if expected_cloud_id and cloud_id_from_token and expected_cloud_id != cloud_id_from_token:
|
|||
|
|
raise AuthError(
|
|||
|
|
f"Jira Cloud 3LO cloudId 不匹配: token 解析为 {cloud_id_from_token},"
|
|||
|
|
f"但 connection_config.cloud_id 配置为 {expected_cloud_id},请重新授权"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if cloud_id_from_token:
|
|||
|
|
product = connection_config.get("product", "jira")
|
|||
|
|
return f"{CLOUD_OAUTH_PROXY_BASE}/{product}/{cloud_id_from_token}"
|
|||
|
|
|
|||
|
|
# Cloud API token: 使用 site URL
|
|||
|
|
site_url = connection_config.get("site_url") or connection_config.get("base_url")
|
|||
|
|
if not site_url:
|
|||
|
|
raise DomainValidationError(
|
|||
|
|
"Jira Cloud 缺少 _resolved_token.extra.cloud_id 且 connection_config.site_url 未配置"
|
|||
|
|
)
|
|||
|
|
return str(site_url).rstrip("/")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_selected_projects(system_config: dict[str, Any]) -> list[str] | None:
|
|||
|
|
"""从 system_config 提取用户选择的项目 key 列表(可选)。
|
|||
|
|
|
|||
|
|
优先从顶层 ``selected_projects`` 读取(见设计文档 §7.4),
|
|||
|
|
回退到 ``discovery_options.selected_projects``(兼容 dynamics365 模式)。
|
|||
|
|
"""
|
|||
|
|
selected = system_config.get("selected_projects")
|
|||
|
|
if not selected:
|
|||
|
|
discovery_options = system_config.get("discovery_options") or {}
|
|||
|
|
selected = discovery_options.get("selected_projects")
|
|||
|
|
if selected:
|
|||
|
|
return list(selected)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# 429 退避 HTTP 请求
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _request_with_retry(
|
|||
|
|
method: str,
|
|||
|
|
url: str,
|
|||
|
|
headers: dict[str, str],
|
|||
|
|
*,
|
|||
|
|
params: dict[str, Any] | None = None,
|
|||
|
|
json_body: dict[str, Any] | None = None,
|
|||
|
|
max_retries: int = _RETRY_MAX_ATTEMPTS,
|
|||
|
|
base_delay: float = _RETRY_BASE_DELAY,
|
|||
|
|
vendor: str = "Atlassian",
|
|||
|
|
) -> httpx.Response:
|
|||
|
|
"""handler 内部 HTTP 调用的 429 退避(仅用于 discover/preview/create 阶段)。
|
|||
|
|
|
|||
|
|
优先使用响应头 ``Retry-After``,否则指数退避(``base_delay * 2 ** attempt``)。
|
|||
|
|
运行时工具执行的 429 退避由框架 executor + 工具 retry_policy 处理。
|
|||
|
|
|
|||
|
|
非 2xx 响应(非 429)通过 ``_map_atlassian_error`` 转换为 ExternalSystemError 子类。
|
|||
|
|
网络层异常(超时 / 连接错误)转换为 ``ExecutionError``。
|
|||
|
|
"""
|
|||
|
|
import httpx
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
async with httpx.AsyncClient() as client:
|
|||
|
|
last_response: httpx.Response | None = None
|
|||
|
|
for attempt in range(max_retries + 1):
|
|||
|
|
response = await client.request(method, url, headers=headers, params=params, json=json_body)
|
|||
|
|
if response.status_code != 429:
|
|||
|
|
last_response = response
|
|||
|
|
break
|
|||
|
|
if attempt == max_retries:
|
|||
|
|
retry_after_header = response.headers.get("Retry-After", "60")
|
|||
|
|
try:
|
|||
|
|
retry_after = int(retry_after_header)
|
|||
|
|
except ValueError:
|
|||
|
|
retry_after = 60
|
|||
|
|
raise RateLimitExceededError(
|
|||
|
|
f"{vendor} 限流,已重试 {max_retries} 次仍失败: {url}",
|
|||
|
|
retry_after=retry_after,
|
|||
|
|
limit_type="qps",
|
|||
|
|
)
|
|||
|
|
retry_after_header = response.headers.get("Retry-After")
|
|||
|
|
if retry_after_header:
|
|||
|
|
try:
|
|||
|
|
delay = float(retry_after_header)
|
|||
|
|
except ValueError:
|
|||
|
|
delay = base_delay * (2**attempt)
|
|||
|
|
else:
|
|||
|
|
delay = base_delay * (2**attempt)
|
|||
|
|
await asyncio.sleep(delay)
|
|||
|
|
except httpx.RequestError as exc:
|
|||
|
|
raise ExecutionError(f"{vendor} 网络请求失败: {exc}") from exc
|
|||
|
|
|
|||
|
|
assert last_response is not None
|
|||
|
|
if last_response.is_success:
|
|||
|
|
return last_response
|
|||
|
|
try:
|
|||
|
|
error_body = last_response.json()
|
|||
|
|
except Exception:
|
|||
|
|
error_body = {"errorMessages": [last_response.text]}
|
|||
|
|
raise _map_atlassian_error(last_response.status_code, error_body, vendor=vendor)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Jira 元数据发现函数
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _list_projects(
|
|||
|
|
base_url: str,
|
|||
|
|
auth_headers: dict[str, str],
|
|||
|
|
api_version: str,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""调用 ``GET /rest/api/{2|3}/project`` 获取项目列表。"""
|
|||
|
|
path = JIRA_PROJECTS_PATH.format(api_version=api_version)
|
|||
|
|
response = await _request_with_retry(
|
|||
|
|
method="GET",
|
|||
|
|
url=f"{base_url}{path}",
|
|||
|
|
headers=auth_headers,
|
|||
|
|
)
|
|||
|
|
data = response.json()
|
|||
|
|
return data if isinstance(data, list) else []
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _list_issue_types_for_project(
|
|||
|
|
base_url: str,
|
|||
|
|
auth_headers: dict[str, str],
|
|||
|
|
project_id_or_key: str,
|
|||
|
|
api_version: str,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""调用 ``GET /rest/api/{2|3}/issue/createmeta/{projectIdOrKey}/issuetypes`` 获取项目下可用 issue types。
|
|||
|
|
|
|||
|
|
替代已弃用的 ``/issue/createmeta`` 旧端点(见设计文档 §4.5)。
|
|||
|
|
"""
|
|||
|
|
path = JIRA_ISSUE_TYPES_PATH.format(
|
|||
|
|
api_version=api_version,
|
|||
|
|
project_id_or_key=project_id_or_key,
|
|||
|
|
)
|
|||
|
|
response = await _request_with_retry(
|
|||
|
|
method="GET",
|
|||
|
|
url=f"{base_url}{path}",
|
|||
|
|
headers=auth_headers,
|
|||
|
|
)
|
|||
|
|
data = response.json()
|
|||
|
|
return data.get("issueTypes", []) if isinstance(data, dict) else []
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _list_fields(
|
|||
|
|
base_url: str,
|
|||
|
|
auth_headers: dict[str, str],
|
|||
|
|
api_version: str,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""调用 ``GET /rest/api/{2|3}/field`` 获取所有字段(含 ``customfield_*``)。"""
|
|||
|
|
path = JIRA_FIELDS_PATH.format(api_version=api_version)
|
|||
|
|
response = await _request_with_retry(
|
|||
|
|
method="GET",
|
|||
|
|
url=f"{base_url}{path}",
|
|||
|
|
headers=auth_headers,
|
|||
|
|
)
|
|||
|
|
data = response.json()
|
|||
|
|
return data if isinstance(data, list) else []
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _list_statuses(
|
|||
|
|
base_url: str,
|
|||
|
|
auth_headers: dict[str, str],
|
|||
|
|
api_version: str,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""调用 ``GET /rest/api/{2|3}/status`` 获取状态列表。"""
|
|||
|
|
path = JIRA_STATUSES_PATH.format(api_version=api_version)
|
|||
|
|
response = await _request_with_retry(
|
|||
|
|
method="GET",
|
|||
|
|
url=f"{base_url}{path}",
|
|||
|
|
headers=auth_headers,
|
|||
|
|
)
|
|||
|
|
data = response.json()
|
|||
|
|
return data if isinstance(data, list) else []
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Confluence 元数据发现函数
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _list_confluence_spaces(
|
|||
|
|
base_url: str,
|
|||
|
|
auth_headers: dict[str, str],
|
|||
|
|
deployment_mode: str,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""获取 Confluence 空间列表。
|
|||
|
|
|
|||
|
|
- Cloud v2: ``GET /wiki/rest/api/v2/spaces``(cursor 分页,首次取第一页)
|
|||
|
|
- DC: ``GET /rest/api/space``(offset 分页,首次取第一页)
|
|||
|
|
"""
|
|||
|
|
if deployment_mode == "cloud":
|
|||
|
|
path = CONFLUENCE_CLOUD_SPACES_PATH
|
|||
|
|
else:
|
|||
|
|
path = CONFLUENCE_DC_SPACES_PATH
|
|||
|
|
|
|||
|
|
response = await _request_with_retry(
|
|||
|
|
method="GET",
|
|||
|
|
url=f"{base_url}{path}",
|
|||
|
|
headers=auth_headers,
|
|||
|
|
params={"limit": 50},
|
|||
|
|
vendor="Confluence",
|
|||
|
|
)
|
|||
|
|
data = response.json()
|
|||
|
|
return data.get("results", []) if isinstance(data, dict) else []
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Jira OperationHandler 实现
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def discover_jira(
|
|||
|
|
source_type: str,
|
|||
|
|
system_config: dict[str, Any],
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""discover handler:发现 Jira 可用项目列表(含 issue types 摘要)。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
项目摘要列表,每项含 ``key`` / ``id`` / ``name`` / ``issue_type_count``。
|
|||
|
|
"""
|
|||
|
|
deployment_mode = _resolve_deployment_mode(system_config)
|
|||
|
|
api_version = _resolve_api_version(system_config, deployment_mode)
|
|||
|
|
base_url = _resolve_base_url(system_config, deployment_mode)
|
|||
|
|
auth_headers = _build_auth_headers(system_config)
|
|||
|
|
|
|||
|
|
projects = await _list_projects(base_url, auth_headers, api_version)
|
|||
|
|
|
|||
|
|
results: list[dict[str, Any]] = []
|
|||
|
|
for project in projects:
|
|||
|
|
project_key = project.get("key", "")
|
|||
|
|
if not project_key:
|
|||
|
|
continue
|
|||
|
|
# 获取项目下 issue types 摘要
|
|||
|
|
try:
|
|||
|
|
issue_types = await _list_issue_types_for_project(base_url, auth_headers, project_key, api_version)
|
|||
|
|
issue_type_count = len(issue_types)
|
|||
|
|
except Exception:
|
|||
|
|
# 单个项目失败不影响整体发现
|
|||
|
|
issue_type_count = 0
|
|||
|
|
|
|||
|
|
results.append(
|
|||
|
|
{
|
|||
|
|
"key": project_key,
|
|||
|
|
"id": project.get("id", ""),
|
|||
|
|
"name": project.get("name", project_key),
|
|||
|
|
"issue_type_count": issue_type_count,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def preview_jira_tools(
|
|||
|
|
source_type: str,
|
|||
|
|
system_config: dict[str, Any],
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""preview_tools handler:预览将生成的工具列表。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。
|
|||
|
|
"""
|
|||
|
|
payload = await _build_jira_generator_payload(system_config)
|
|||
|
|
generator = JiraConfigGenerator()
|
|||
|
|
return await generator.generate(payload)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def create_jira_tools(
|
|||
|
|
source_type: str,
|
|||
|
|
system_config: dict[str, Any],
|
|||
|
|
) -> GeneratedToolsDraft:
|
|||
|
|
"""create_tools handler:生成工具草稿(不直接持久化)。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
GeneratedToolsDraft,由 use_cases 层统一持久化。
|
|||
|
|
"""
|
|||
|
|
payload = await _build_jira_generator_payload(system_config)
|
|||
|
|
generator = JiraConfigGenerator()
|
|||
|
|
tool_configs = await generator.generate(payload)
|
|||
|
|
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _build_jira_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""从 system_config 构造 Jira 生成器 payload。
|
|||
|
|
|
|||
|
|
流程:
|
|||
|
|
1. 解析 deployment_mode / api_version / base_url / auth_headers / auth_type / auth_config
|
|||
|
|
2. 发现项目列表
|
|||
|
|
3. 为每个项目发现 issue types
|
|||
|
|
4. 发现全局字段列表(含 customfield_*)
|
|||
|
|
5. 发现状态列表
|
|||
|
|
6. 汇总为生成器 payload
|
|||
|
|
"""
|
|||
|
|
deployment_mode = _resolve_deployment_mode(system_config)
|
|||
|
|
api_version = _resolve_api_version(system_config, deployment_mode)
|
|||
|
|
base_url = _resolve_base_url(system_config, deployment_mode)
|
|||
|
|
auth_headers = _build_auth_headers(system_config)
|
|||
|
|
auth_type = _resolve_auth_type(system_config)
|
|||
|
|
auth_config = system_config.get("auth_config") or {}
|
|||
|
|
selected_projects = _resolve_selected_projects(system_config)
|
|||
|
|
|
|||
|
|
# 发现项目列表
|
|||
|
|
projects = await _list_projects(base_url, auth_headers, api_version)
|
|||
|
|
|
|||
|
|
# 按选择过滤项目
|
|||
|
|
if selected_projects:
|
|||
|
|
projects = [p for p in projects if p.get("key") in selected_projects]
|
|||
|
|
|
|||
|
|
# 为每个项目发现 issue types
|
|||
|
|
issue_type_map: dict[str, list[dict[str, Any]]] = {}
|
|||
|
|
field_map: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|||
|
|
for project in projects:
|
|||
|
|
project_key = project.get("key", "")
|
|||
|
|
if not project_key:
|
|||
|
|
continue
|
|||
|
|
issue_types = await _list_issue_types_for_project(base_url, auth_headers, project_key, api_version)
|
|||
|
|
issue_type_map[project_key] = issue_types
|
|||
|
|
|
|||
|
|
# 发现全局字段列表(含 customfield_*)
|
|||
|
|
fields = await _list_fields(base_url, auth_headers, api_version)
|
|||
|
|
|
|||
|
|
# 发现状态列表
|
|||
|
|
statuses = await _list_statuses(base_url, auth_headers, api_version)
|
|||
|
|
status_map: dict[str, list[dict[str, Any]]] = {}
|
|||
|
|
for project in projects:
|
|||
|
|
project_key = project.get("key", "")
|
|||
|
|
if project_key:
|
|||
|
|
status_map[project_key] = statuses
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"projects": projects,
|
|||
|
|
"issue_type_map": issue_type_map,
|
|||
|
|
"field_map": field_map,
|
|||
|
|
"fields": fields,
|
|||
|
|
"status_map": status_map,
|
|||
|
|
"base_url": base_url,
|
|||
|
|
"api_version": api_version,
|
|||
|
|
"deployment_mode": deployment_mode,
|
|||
|
|
"selected_projects": selected_projects,
|
|||
|
|
"auth_type": auth_type,
|
|||
|
|
"auth_config": auth_config,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Confluence OperationHandler 实现
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def discover_confluence(
|
|||
|
|
source_type: str,
|
|||
|
|
system_config: dict[str, Any],
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""discover handler:发现 Confluence 可用空间列表。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
空间摘要列表,每项含 ``id`` / ``key`` / ``name`` / ``type``。
|
|||
|
|
"""
|
|||
|
|
deployment_mode = _resolve_deployment_mode(system_config)
|
|||
|
|
base_url = _resolve_base_url(system_config, deployment_mode)
|
|||
|
|
auth_headers = _build_auth_headers(system_config)
|
|||
|
|
|
|||
|
|
spaces = await _list_confluence_spaces(base_url, auth_headers, deployment_mode)
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
"id": s.get("id", ""),
|
|||
|
|
"key": s.get("key", ""),
|
|||
|
|
"name": s.get("name", ""),
|
|||
|
|
"type": s.get("type", ""),
|
|||
|
|
}
|
|||
|
|
for s in spaces
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def preview_confluence_tools(
|
|||
|
|
source_type: str,
|
|||
|
|
system_config: dict[str, Any],
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""preview_tools handler:预览将生成的 Confluence 工具列表。"""
|
|||
|
|
payload = await _build_confluence_generator_payload(system_config)
|
|||
|
|
generator = ConfluenceConfigGenerator()
|
|||
|
|
return await generator.generate(payload)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def create_confluence_tools(
|
|||
|
|
source_type: str,
|
|||
|
|
system_config: dict[str, Any],
|
|||
|
|
) -> GeneratedToolsDraft:
|
|||
|
|
"""create_tools handler:生成 Confluence 工具草稿(不直接持久化)。"""
|
|||
|
|
payload = await _build_confluence_generator_payload(system_config)
|
|||
|
|
generator = ConfluenceConfigGenerator()
|
|||
|
|
tool_configs = await generator.generate(payload)
|
|||
|
|
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _build_confluence_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""从 system_config 构造 Confluence 生成器 payload。"""
|
|||
|
|
deployment_mode = _resolve_deployment_mode(system_config)
|
|||
|
|
api_version = _resolve_api_version(system_config, deployment_mode)
|
|||
|
|
base_url = _resolve_base_url(system_config, deployment_mode)
|
|||
|
|
auth_headers = _build_auth_headers(system_config)
|
|||
|
|
auth_type = _resolve_auth_type(system_config)
|
|||
|
|
auth_config = system_config.get("auth_config") or {}
|
|||
|
|
|
|||
|
|
spaces = await _list_confluence_spaces(base_url, auth_headers, deployment_mode)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"spaces": spaces,
|
|||
|
|
"base_url": base_url,
|
|||
|
|
"api_version": api_version,
|
|||
|
|
"deployment_mode": deployment_mode,
|
|||
|
|
"auth_type": auth_type,
|
|||
|
|
"auth_config": auth_config,
|
|||
|
|
}
|