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>
281 lines
8.9 KiB
Python
281 lines
8.9 KiB
Python
"""
|
||
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
|