From 8313a3787bd36448a6fc61337500e20c6ac70b0d Mon Sep 17 00:00:00 2001 From: OG T Date: Tue, 31 Mar 2026 16:06:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor(api):=20Phase=2022=20P0=20leWOOOgo=20?= =?UTF-8?q?=E6=A8=A1=E7=B5=84=E5=8C=96=E4=BF=AE=E5=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Router 層禁止直接 httpx.AsyncClient,抽取到 Service 層: 新增 Services: - OpenClawHttpService: Error 分析/Code Review/CI 診斷 - GitHubApiService: PR Diff 取得 - HealthCheckService: HTTP/PostgreSQL/Redis 健康檢查 修改 Routers: - sentry_webhook.py: 使用 OpenClawHttpService - github_webhook.py: 使用 GitHubApiService + OpenClawHttpService - health.py: 使用 HealthCheckService 遵循規範: - Skill 09: Router 層禁止直接外部 API 呼叫 - feedback_lewooogo_modular_enforcement.md Co-Authored-By: Claude Opus 4.5 --- apps/api/src/api/v1/github_webhook.py | 185 +++++------- apps/api/src/api/v1/health.py | 98 +++--- apps/api/src/api/v1/sentry_webhook.py | 38 +-- apps/api/src/services/github_api_service.py | 117 ++++++++ apps/api/src/services/health_check_service.py | 217 ++++++++++++++ .../api/src/services/openclaw_http_service.py | 280 ++++++++++++++++++ 6 files changed, 730 insertions(+), 205 deletions(-) create mode 100644 apps/api/src/services/github_api_service.py create mode 100644 apps/api/src/services/health_check_service.py create mode 100644 apps/api/src/services/openclaw_http_service.py diff --git a/apps/api/src/api/v1/github_webhook.py b/apps/api/src/api/v1/github_webhook.py index a6dbc4af7..d51fd0602 100644 --- a/apps/api/src/api/v1/github_webhook.py +++ b/apps/api/src/api/v1/github_webhook.py @@ -37,7 +37,6 @@ import json import uuid from typing import Literal -import httpx from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status from pydantic import BaseModel, Field @@ -50,7 +49,9 @@ from src.models.approval import ( RiskLevel, ) from src.services.approval_db import get_approval_service +from src.services.github_api_service import get_github_api_service from src.services.github_webhook_service import get_github_webhook_service +from src.services.openclaw_http_service import get_openclaw_http_service from src.services.telegram_gateway import get_telegram_gateway from src.utils.timezone import now_taipei_iso @@ -967,30 +968,11 @@ async def fetch_pr_diff(diff_url: str) -> str: Returns: str: diff 內容 + + Phase 22 P0 修復: 使用 GitHubApiService (2026-03-31) """ - try: - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get( - diff_url, - headers={"Accept": "application/vnd.github.v3.diff"}, - ) - if response.status_code == 200: - # 限制 diff 大小 (避免 LLM token 過多) - diff_content = response.text - max_chars = 50000 # 約 12500 tokens - if len(diff_content) > max_chars: - diff_content = diff_content[:max_chars] + "\n... (truncated)" - return diff_content - else: - logger.warning( - "github_diff_fetch_failed", - url=diff_url, - status=response.status_code, - ) - return "" - except Exception as e: - logger.warning("github_diff_fetch_error", error=str(e)) - return "" + service = get_github_api_service() + return await service.fetch_pr_diff(diff_url) async def call_openclaw_code_review( @@ -1007,37 +989,27 @@ async def call_openclaw_code_review( 優先使用 Ollama (本地,零成本) Fallback: Claude (大型 PR) + + Phase 22 P0 修復: 使用 OpenClawHttpService (2026-03-31) """ try: - async with httpx.AsyncClient(timeout=120.0) as client: - response = await client.post( - f"{OPENCLAW_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": True, # 優先 Ollama - }, - ) + service = get_openclaw_http_service() + data = await service.code_review( + repo_name=repo_name, + pr_title=pr_title, + pr_body=pr_body, + diff_content=diff_content, + changed_files=changed_files, + additions=additions, + deletions=deletions, + prefer_local=True, + timeout=120.0, + ) - if response.status_code == 200: - data = response.json() - return CodeReviewResult(**data) - 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") + if data: + return CodeReviewResult(**data) return None + except Exception as e: logger.exception("openclaw_code_review_error", error=str(e)) return None @@ -1051,33 +1023,24 @@ async def call_openclaw_push_review( ) -> CodeReviewResult | None: """ 呼叫 OpenClaw 進行 Push 代碼審查 + + Phase 22 P0 修復: 使用 OpenClawHttpService (2026-03-31) """ try: - async with httpx.AsyncClient(timeout=120.0) as client: - response = await client.post( - f"{OPENCLAW_URL}/api/v1/analyze/push-review", - json={ - "repo": repo_name, - "ref": ref, - "commits": commits, - "files_changed": files_changed, - "prefer_local": True, - }, - ) + service = get_openclaw_http_service() + data = await service.push_review( + repo_name=repo_name, + ref=ref, + commits=commits, + files_changed=files_changed, + prefer_local=True, + timeout=120.0, + ) - if response.status_code == 200: - data = response.json() - return CodeReviewResult(**data) - else: - logger.warning( - "openclaw_push_review_failed", - status=response.status_code, - ) - return None - - except httpx.TimeoutException: - logger.warning("openclaw_push_review_timeout") + if data: + return CodeReviewResult(**data) return None + except Exception as e: logger.exception("openclaw_push_review_error", error=str(e)) return None @@ -1091,61 +1054,45 @@ async def call_openclaw_ci_diagnosis( 呼叫 OpenClaw 進行 CI 失敗診斷 (Phase 13.1 #76) 分析 CI/CD pipeline 失敗原因,提供根因分析和修復建議 + + Phase 22 P0 修復: 使用 OpenClawHttpService (2026-03-31) """ try: - async with httpx.AsyncClient(timeout=120.0) as client: - response = await client.post( - f"{OPENCLAW_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": True, # 優先 Ollama - }, + service = get_openclaw_http_service() + data = await service.ci_diagnosis( + repo_name=repo_name, + failure_context=failure_context, + prefer_local=True, + timeout=120.0, + ) + + if data: + return CIFailureDiagnosis(**data) + else: + # 返回基本診斷結果 (API 失敗時的 fallback) + return CIFailureDiagnosis( + summary=f"CI workflow '{failure_context.get('workflow_name')}' failed", + root_cause="OpenClaw API unavailable, manual investigation required", + error_type="unknown", + suggestions=["Check workflow logs manually", "Verify runner status"], + auto_fixable=False, + risk_level="medium", + analyzed_by="fallback", + confidence=0.0, # 🔴 Fallback 不是 AI 分析 ) - if response.status_code == 200: - data = response.json() - return CIFailureDiagnosis(**data) - else: - logger.warning( - "openclaw_ci_diagnosis_failed", - status=response.status_code, - response=response.text[:200], - ) - # 返回基本診斷結果 (API 失敗時的 fallback) - return CIFailureDiagnosis( - summary=f"CI workflow '{failure_context.get('workflow_name')}' failed", - root_cause="OpenClaw API unavailable, manual investigation required", - error_type="unknown", - suggestions=["Check workflow logs manually", "Verify runner status"], - auto_fixable=False, - risk_level="medium", - analyzed_by="fallback", - confidence=0.0, # 🔴 Fallback 不是 AI 分析 - ) - - except httpx.TimeoutException: - logger.warning("openclaw_ci_diagnosis_timeout") + except Exception as e: + logger.exception("openclaw_ci_diagnosis_error", error=str(e)) return CIFailureDiagnosis( - summary="CI diagnosis timeout", - root_cause="OpenClaw API timeout", - error_type="timeout", + summary="CI diagnosis error", + root_cause=f"Exception: {str(e)}", + error_type="error", suggestions=["Check OpenClaw service status"], auto_fixable=False, risk_level="low", analyzed_by="fallback", - confidence=0.0, # 🔴 Fallback 不是 AI 分析 + confidence=0.0, ) - except Exception as e: - logger.exception("openclaw_ci_diagnosis_error", error=str(e)) - return None async def send_ci_failure_telegram_alert( diff --git a/apps/api/src/api/v1/health.py b/apps/api/src/api/v1/health.py index 4620225d0..0cf4bcad0 100644 --- a/apps/api/src/api/v1/health.py +++ b/apps/api/src/api/v1/health.py @@ -20,12 +20,12 @@ import asyncio from datetime import UTC, datetime from typing import Literal -import httpx from fastapi import APIRouter from pydantic import BaseModel from src.core.config import settings from src.core.logging import get_logger +from src.services.health_check_service import get_health_check_service router = APIRouter() logger = get_logger("awoooi.health") @@ -61,78 +61,48 @@ async def _http_health_check( url: str, path: str = "/health", ) -> ComponentHealth: - """Generic async HTTP health check""" - if settings.MOCK_MODE: - # Elegant mock: simulate varied latencies - import random - latency = random.uniform(1.0, 15.0) - return ComponentHealth(status="up", latency_ms=round(latency, 2)) + """ + Generic async HTTP health check - try: - start = asyncio.get_event_loop().time() - async with httpx.AsyncClient(timeout=settings.HEALTH_CHECK_TIMEOUT) as client: - response = await client.get(f"{url}{path}") - response.raise_for_status() - latency = (asyncio.get_event_loop().time() - start) * 1000 - return ComponentHealth(status="up", latency_ms=round(latency, 2)) - except httpx.TimeoutException: - logger.warning(f"{name}_health_check_timeout", url=url) - return ComponentHealth(status="down", error="timeout") - except httpx.ConnectError: - logger.warning(f"{name}_health_check_connect_error", url=url) - return ComponentHealth(status="down", error="connection refused") - except Exception as e: - logger.warning(f"{name}_health_check_failed", url=url, error=str(e)) - return ComponentHealth(status="down", error=str(e)) + Phase 22 P0 修復: 使用 HealthCheckService (2026-03-31) + """ + service = get_health_check_service() + result = await service.http_health_check(name=name, url=url, path=path) + return ComponentHealth( + status=result.status, + latency_ms=result.latency_ms, + error=result.error, + ) async def check_postgresql() -> ComponentHealth: - """Async PostgreSQL health check via TCP connect""" - if settings.MOCK_MODE: - import random - return ComponentHealth(status="up", latency_ms=round(random.uniform(0.5, 3.0), 2)) + """ + Async PostgreSQL health check via TCP connect - try: - start = asyncio.get_event_loop().time() - # Simple TCP connect check (actual query would need asyncpg) - reader, writer = await asyncio.wait_for( - asyncio.open_connection("192.168.0.188", 5432), - timeout=settings.HEALTH_CHECK_TIMEOUT, - ) - writer.close() - await writer.wait_closed() - latency = (asyncio.get_event_loop().time() - start) * 1000 - return ComponentHealth(status="up", latency_ms=round(latency, 2)) - except TimeoutError: - logger.warning("postgresql_health_check_timeout") - return ComponentHealth(status="down", error="timeout") - except Exception as e: - logger.warning("postgresql_health_check_failed", error=str(e)) - return ComponentHealth(status="down", error=str(e)) + Phase 22 P0 修復: 使用 HealthCheckService (2026-03-31) + """ + service = get_health_check_service() + result = await service.postgresql_tcp_check() + return ComponentHealth( + status=result.status, + latency_ms=result.latency_ms, + error=result.error, + ) async def check_redis() -> ComponentHealth: - """Async Redis health check via TCP connect""" - if settings.MOCK_MODE: - import random - return ComponentHealth(status="up", latency_ms=round(random.uniform(0.3, 2.0), 2)) + """ + Async Redis health check via PING - try: - start = asyncio.get_event_loop().time() - reader, writer = await asyncio.wait_for( - asyncio.open_connection("192.168.0.188", 6380), - timeout=settings.HEALTH_CHECK_TIMEOUT, - ) - writer.close() - await writer.wait_closed() - latency = (asyncio.get_event_loop().time() - start) * 1000 - return ComponentHealth(status="up", latency_ms=round(latency, 2)) - except TimeoutError: - logger.warning("redis_health_check_timeout") - return ComponentHealth(status="down", error="timeout") - except Exception as e: - logger.warning("redis_health_check_failed", error=str(e)) - return ComponentHealth(status="down", error=str(e)) + Phase 22 P0 修復: 使用 HealthCheckService (2026-03-31) + """ + service = get_health_check_service() + result = await service.redis_ping_check() + return ComponentHealth( + status=result.status, + latency_ms=result.latency_ms, + error=result.error, + ) async def check_ollama() -> ComponentHealth: diff --git a/apps/api/src/api/v1/sentry_webhook.py b/apps/api/src/api/v1/sentry_webhook.py index 4ace372de..06b24dc1e 100644 --- a/apps/api/src/api/v1/sentry_webhook.py +++ b/apps/api/src/api/v1/sentry_webhook.py @@ -16,13 +16,11 @@ AWOOOI API - Sentry Webhook Handler import uuid -import httpx import structlog from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from pydantic import BaseModel from src.core.circuit_breaker import get_openclaw_guard -from src.core.config import settings from src.core.metrics import ( record_alert_chain_failure, record_alert_chain_success, @@ -37,6 +35,7 @@ from src.models.approval import ( ) from src.services.anomaly_counter import get_anomaly_counter from src.services.approval_db import get_approval_service +from src.services.openclaw_http_service import get_openclaw_http_service from src.services.sentry_service import get_sentry_service from src.services.telegram_gateway import get_telegram_gateway from src.utils.timezone import now_taipei_iso @@ -287,6 +286,8 @@ async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | N 優先使用 Ollama (本地,零成本) Fallback: Claude (高嚴重性) + + Phase 22 P0 修復: 使用 OpenClawHttpService (2026-03-31) """ guard = get_openclaw_guard() @@ -301,28 +302,21 @@ async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | N # ADR-038 Layer 2: Concurrency Semaphore 排隊 async with guard.semaphore: try: - async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.post( - f"{settings.OPENCLAW_URL}/api/v1/analyze/error", - json={ - "error_context": error_context, - "prefer_local": True, # 優先 Ollama - } - ) + # Phase 22 P0: 使用 Service 層而非直接 httpx + service = get_openclaw_http_service() + data = await service.analyze_error( + error_context=error_context, + prefer_local=True, + timeout=60.0, + ) - if response.status_code == 200: - data = response.json() - guard.record_success() - return ErrorAnalysisResult(**data) - else: - logger.warning(f"OpenClaw returned {response.status_code}") - guard.record_failure() - return None + if data: + guard.record_success() + return ErrorAnalysisResult(**data) + else: + guard.record_failure() + return None - except httpx.TimeoutException: - logger.warning("OpenClaw analysis timeout") - guard.record_failure() - return None except Exception as e: logger.exception(f"OpenClaw call failed: {e}") guard.record_failure() diff --git a/apps/api/src/services/github_api_service.py b/apps/api/src/services/github_api_service.py new file mode 100644 index 000000000..d4d02aa7e --- /dev/null +++ b/apps/api/src/services/github_api_service.py @@ -0,0 +1,117 @@ +""" +GitHub API Service - 統一 GitHub API 呼叫 +========================================= +Phase 22 P0 修復: Router 層禁止直接 httpx.AsyncClient + +遵循規範: +- Skill 09: Router 層禁止直接外部 API 呼叫 +- feedback_lewooogo_modular_enforcement.md: Service 層封裝 + +功能: +- Fetch PR Diff (取得 PR 差異內容) +- 未來擴展: PR Comments, Status Checks, etc. + +版本: v1.0 +建立: 2026-03-31 (台北時區) +建立者: Claude Code (首席架構師 P0 修復) +""" + +import httpx +import structlog + +logger = structlog.get_logger(__name__) + + +# ============================================================================= +# Constants +# ============================================================================= + +# 最大 diff 字元數 (約 12500 tokens) +MAX_DIFF_CHARS = 50000 + + +# ============================================================================= +# GitHub API Service +# ============================================================================= + + +class GitHubApiService: + """ + GitHub API Service + + 統一 GitHub API 呼叫,符合 leWOOOgo 積木化原則 + + 2026-03-31 Claude Code (Phase 22 P0 修復) + """ + + def __init__( + self, + default_timeout: float = 30.0, + max_diff_chars: int = MAX_DIFF_CHARS, + ): + self._default_timeout = default_timeout + self._max_diff_chars = max_diff_chars + + async def fetch_pr_diff( + self, + diff_url: str, + timeout: float | None = None, + ) -> str: + """ + 取得 PR diff 內容 + + Args: + diff_url: GitHub diff URL + timeout: 超時秒數 (預設 30s) + + Returns: + str: diff 內容 (超過限制會截斷) + """ + try: + async with httpx.AsyncClient( + timeout=timeout or self._default_timeout + ) as client: + response = await client.get( + diff_url, + headers={"Accept": "application/vnd.github.v3.diff"}, + ) + + if response.status_code == 200: + diff_content = response.text + + # 限制 diff 大小 (避免 LLM token 過多) + if len(diff_content) > self._max_diff_chars: + diff_content = ( + diff_content[: self._max_diff_chars] + "\n... (truncated)" + ) + + return diff_content + else: + logger.warning( + "github_diff_fetch_failed", + url=diff_url, + status=response.status_code, + ) + return "" + + except httpx.TimeoutException: + logger.warning("github_diff_fetch_timeout", url=diff_url) + return "" + except Exception as e: + logger.warning("github_diff_fetch_error", error=str(e)) + return "" + + +# ============================================================================= +# Singleton +# ============================================================================= + +_github_api_service: GitHubApiService | None = None + + +def get_github_api_service() -> GitHubApiService: + """取得 GitHubApiService singleton""" + global _github_api_service + if _github_api_service is None: + _github_api_service = GitHubApiService() + return _github_api_service diff --git a/apps/api/src/services/health_check_service.py b/apps/api/src/services/health_check_service.py new file mode 100644 index 000000000..8b6fe9a41 --- /dev/null +++ b/apps/api/src/services/health_check_service.py @@ -0,0 +1,217 @@ +""" +Health Check Service - 統一健康檢查 +=================================== +Phase 22 P0 修復: Router 層禁止直接 httpx.AsyncClient + +遵循規範: +- Skill 09: Router 層禁止直接外部 API 呼叫 +- feedback_lewooogo_modular_enforcement.md: Service 層封裝 + +功能: +- HTTP 健康檢查 (通用) +- PostgreSQL TCP 檢查 +- Redis PING 檢查 + +版本: v1.0 +建立: 2026-03-31 (台北時區) +建立者: Claude Code (首席架構師 P0 修復) +""" + +import asyncio +from dataclasses import dataclass + +import httpx +import structlog + +from src.core.config import settings + +logger = structlog.get_logger(__name__) + + +# ============================================================================= +# Response Models +# ============================================================================= + + +@dataclass +class ComponentHealth: + """組件健康狀態""" + + status: str # "up" | "down" + latency_ms: float | None = None + error: str | None = None + + +# ============================================================================= +# Health Check Service +# ============================================================================= + + +class HealthCheckService: + """ + Health Check Service + + 統一健康檢查,符合 leWOOOgo 積木化原則 + + 2026-03-31 Claude Code (Phase 22 P0 修復) + """ + + def __init__( + self, + default_timeout: float | None = None, + ): + self._default_timeout = default_timeout or settings.HEALTH_CHECK_TIMEOUT + + async def http_health_check( + self, + name: str, + url: str, + path: str = "/health", + timeout: float | None = None, + ) -> ComponentHealth: + """ + 通用 HTTP 健康檢查 + + Args: + name: 組件名稱 (用於日誌) + url: 基礎 URL + path: 健康檢查路徑 + timeout: 超時秒數 + + Returns: + ComponentHealth + """ + if settings.MOCK_MODE: + # Mock 模式: 模擬延遲 + import random + + latency = random.uniform(1.0, 15.0) + return ComponentHealth(status="up", latency_ms=round(latency, 2)) + + try: + start = asyncio.get_event_loop().time() + async with httpx.AsyncClient( + timeout=timeout or self._default_timeout + ) as client: + response = await client.get(f"{url}{path}") + response.raise_for_status() + latency = (asyncio.get_event_loop().time() - start) * 1000 + return ComponentHealth(status="up", latency_ms=round(latency, 2)) + + except httpx.TimeoutException: + logger.warning(f"{name}_health_check_timeout", url=url) + return ComponentHealth(status="down", error="timeout") + except httpx.ConnectError: + logger.warning(f"{name}_health_check_connect_error", url=url) + return ComponentHealth(status="down", error="connection refused") + except Exception as e: + logger.warning(f"{name}_health_check_failed", url=url, error=str(e)) + return ComponentHealth(status="down", error=str(e)) + + async def postgresql_tcp_check( + self, + host: str = "192.168.0.188", + port: int = 5432, + timeout: float | None = None, + ) -> ComponentHealth: + """ + PostgreSQL TCP 連線檢查 + + Args: + host: PostgreSQL 主機 + port: PostgreSQL 端口 + timeout: 超時秒數 + + Returns: + ComponentHealth + """ + if settings.MOCK_MODE: + import random + + return ComponentHealth( + status="up", latency_ms=round(random.uniform(0.5, 3.0), 2) + ) + + try: + start = asyncio.get_event_loop().time() + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), + timeout=timeout or self._default_timeout, + ) + writer.close() + await writer.wait_closed() + latency = (asyncio.get_event_loop().time() - start) * 1000 + return ComponentHealth(status="up", latency_ms=round(latency, 2)) + + except TimeoutError: + logger.warning("postgresql_health_check_timeout", host=host, port=port) + return ComponentHealth(status="down", error="timeout") + except ConnectionRefusedError: + logger.warning( + "postgresql_health_check_refused", + host=host, + port=port, + ) + return ComponentHealth(status="down", error="connection refused") + except Exception as e: + logger.warning( + "postgresql_health_check_failed", + host=host, + port=port, + error=str(e), + ) + return ComponentHealth(status="down", error=str(e)) + + async def redis_ping_check( + self, + timeout: float | None = None, + ) -> ComponentHealth: + """ + Redis PING 檢查 + + Args: + timeout: 超時秒數 + + Returns: + ComponentHealth + """ + if settings.MOCK_MODE: + import random + + return ComponentHealth( + status="up", latency_ms=round(random.uniform(0.1, 1.0), 2) + ) + + try: + from src.core.redis_client import get_redis + + start = asyncio.get_event_loop().time() + redis = get_redis() + await asyncio.wait_for( + redis.ping(), + timeout=timeout or self._default_timeout, + ) + latency = (asyncio.get_event_loop().time() - start) * 1000 + return ComponentHealth(status="up", latency_ms=round(latency, 2)) + + except TimeoutError: + logger.warning("redis_health_check_timeout") + return ComponentHealth(status="down", error="timeout") + except Exception as e: + logger.warning("redis_health_check_failed", error=str(e)) + return ComponentHealth(status="down", error=str(e)) + + +# ============================================================================= +# Singleton +# ============================================================================= + +_health_check_service: HealthCheckService | None = None + + +def get_health_check_service() -> HealthCheckService: + """取得 HealthCheckService singleton""" + global _health_check_service + if _health_check_service is None: + _health_check_service = HealthCheckService() + return _health_check_service diff --git a/apps/api/src/services/openclaw_http_service.py b/apps/api/src/services/openclaw_http_service.py new file mode 100644 index 000000000..9a0800cd4 --- /dev/null +++ b/apps/api/src/services/openclaw_http_service.py @@ -0,0 +1,280 @@ +""" +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