""" OpenClaw HTTP Service - 統一 OpenClaw API 呼叫 ============================================== Phase 22 P0 修復: Router 層禁止直接 httpx.AsyncClient 遵循規範: - Skill 09: Router 層禁止直接外部 API 呼叫 - feedback_lewooogo_modular_enforcement.md: Service 層封裝 功能: - Error Analysis (Sentry 錯誤分析) - Code Review (PR 代碼審查) - Push Review (Push 審查) - CI Diagnosis (CI 失敗診斷) 設計原則: - 返回 dict | None,由調用者決定如何轉換為具體類型 - 保持與現有調用者的相容性 版本: v1.0 建立: 2026-03-31 (台北時區) 建立者: Claude Code (首席架構師 P0 修復) """ import httpx import structlog from src.core.config import settings logger = structlog.get_logger(__name__) # ============================================================================= # OpenClaw HTTP Service # ============================================================================= class OpenClawHttpService: """ OpenClaw HTTP Service 統一 OpenClaw API 呼叫,符合 leWOOOgo 積木化原則 2026-03-31 Claude Code (Phase 22 P0 修復) """ def __init__( self, base_url: str | None = None, default_timeout: float = 60.0, ): self._base_url = base_url or settings.OPENCLAW_URL self._default_timeout = default_timeout async def analyze_error( self, error_context: dict, prefer_local: bool = True, timeout: float | None = None, ) -> dict | None: """ 呼叫 OpenClaw Error Analyzer Agent Args: error_context: 錯誤上下文 prefer_local: 優先使用 Ollama (本地,零成本) timeout: 超時秒數 (預設 60s) Returns: dict | None: API 回應 JSON,由調用者轉換為具體類型 """ try: async with httpx.AsyncClient( timeout=timeout or self._default_timeout ) as client: response = await client.post( f"{self._base_url}/api/v1/analyze/error", json={ "error_context": error_context, "prefer_local": prefer_local, }, ) if response.status_code == 200: return response.json() else: logger.warning( "openclaw_analyze_error_failed", status=response.status_code, ) return None except httpx.TimeoutException: logger.warning("openclaw_analyze_error_timeout") return None except Exception as e: logger.exception("openclaw_analyze_error_exception", error=str(e)) return None async def code_review( self, repo_name: str, pr_title: str, pr_body: str, diff_content: str, changed_files: int, additions: int, deletions: int, prefer_local: bool = True, timeout: float = 120.0, ) -> dict | None: """ 呼叫 OpenClaw 進行 PR 代碼審查 Args: repo_name: 倉庫名稱 pr_title: PR 標題 pr_body: PR 描述 diff_content: diff 內容 changed_files: 變更檔案數 additions: 新增行數 deletions: 刪除行數 prefer_local: 優先 Ollama timeout: 超時秒數 (預設 120s) Returns: dict | None: API 回應 JSON """ try: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post( f"{self._base_url}/api/v1/analyze/code-review", json={ "repo": repo_name, "pr_title": pr_title, "pr_body": pr_body, "diff": diff_content, "changed_files": changed_files, "additions": additions, "deletions": deletions, "prefer_local": prefer_local, }, ) if response.status_code == 200: return response.json() else: logger.warning( "openclaw_code_review_failed", status=response.status_code, response=response.text[:200], ) return None except httpx.TimeoutException: logger.warning("openclaw_code_review_timeout") return None except Exception as e: logger.exception("openclaw_code_review_exception", error=str(e)) return None async def push_review( self, repo_name: str, ref: str, commits: list[dict], files_changed: dict, prefer_local: bool = True, timeout: float = 120.0, ) -> dict | None: """ 呼叫 OpenClaw 進行 Push 代碼審查 Args: repo_name: 倉庫名稱 ref: Git ref commits: commit 列表 files_changed: 變更檔案 prefer_local: 優先 Ollama timeout: 超時秒數 Returns: dict | None: API 回應 JSON """ try: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post( f"{self._base_url}/api/v1/analyze/push-review", json={ "repo": repo_name, "ref": ref, "commits": commits, "files_changed": files_changed, "prefer_local": prefer_local, }, ) if response.status_code == 200: return response.json() else: logger.warning( "openclaw_push_review_failed", status=response.status_code, ) return None except httpx.TimeoutException: logger.warning("openclaw_push_review_timeout") return None except Exception as e: logger.exception("openclaw_push_review_exception", error=str(e)) return None async def ci_diagnosis( self, repo_name: str, failure_context: dict, prefer_local: bool = True, timeout: float = 120.0, ) -> dict | None: """ 呼叫 OpenClaw 進行 CI 失敗診斷 Args: repo_name: 倉庫名稱 failure_context: 失敗上下文 prefer_local: 優先 Ollama timeout: 超時秒數 Returns: dict | None: API 回應 JSON """ try: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post( f"{self._base_url}/api/v1/analyze/ci-failure", json={ "repo": repo_name, "workflow_name": failure_context.get("workflow_name"), "conclusion": failure_context.get("conclusion"), "head_sha": failure_context.get("head_sha"), "head_branch": failure_context.get("head_branch"), "event_trigger": failure_context.get("event_trigger"), "run_number": failure_context.get("run_number"), "run_attempt": failure_context.get("run_attempt"), "workflow_url": failure_context.get("html_url"), "prefer_local": prefer_local, }, ) if response.status_code == 200: return response.json() else: logger.warning( "openclaw_ci_diagnosis_failed", status=response.status_code, ) return None except httpx.TimeoutException: logger.warning("openclaw_ci_diagnosis_timeout") return None except Exception as e: logger.exception("openclaw_ci_diagnosis_exception", error=str(e)) return None # ============================================================================= # Singleton # ============================================================================= _openclaw_http_service: OpenClawHttpService | None = None def get_openclaw_http_service() -> OpenClawHttpService: """取得 OpenClawHttpService singleton""" global _openclaw_http_service if _openclaw_http_service is None: _openclaw_http_service = OpenClawHttpService() return _openclaw_http_service