refactor(api): Phase 22 P0 leWOOOgo 模組化修復
Some checks failed
E2E Health Check / e2e-health (push) Has been cancelled
Some checks failed
E2E Health Check / e2e-health (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
117
apps/api/src/services/github_api_service.py
Normal file
117
apps/api/src/services/github_api_service.py
Normal file
@@ -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
|
||||
217
apps/api/src/services/health_check_service.py
Normal file
217
apps/api/src/services/health_check_service.py
Normal file
@@ -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
|
||||
280
apps/api/src/services/openclaw_http_service.py
Normal file
280
apps/api/src/services/openclaw_http_service.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user