"""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, }