""" AWOOOI — Local Code Review Service (Phase 32, ADR-067) ====================================================== 使用 qwen2.5-coder:7b 對 Gitea PR diff 進行自動審查 Fallback: Gemini (diff > 50KB 或 Ollama 超時) 觸發: Gitea webhook push event (PR opened/updated) 雙寫: Redis TTL 7d + PostgreSQL pr_reviews 表永久儲存 並發: semaphore 最多 2 個同時審查 2026-04-10 Claude Sonnet 4.6 Asia/Taipei """ from __future__ import annotations import asyncio from typing import Any import httpx import structlog from src.core.config import get_settings from src.services.model_registry import get_model from src.services.ollama_endpoint_resolver import resolve_ollama_order logger = structlog.get_logger(__name__) settings = get_settings() # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.code_review 讀取 _MODEL_OLLAMA = get_model("ollama", "code_review") _TIMEOUT_OLLAMA = 120.0 _MAX_DIFF_BYTES = 50 * 1024 # 50KB → fallback to Gemini _SEMAPHORE = asyncio.Semaphore(2) # 最多 2 個同時審查 # Redis cache TTL: 7 days _CACHE_TTL = 7 * 86400 class LocalCodeReviewService: """PR 自動審查服務""" def __init__(self) -> None: self._http: httpx.AsyncClient | None = None async def _get_http(self) -> httpx.AsyncClient: if self._http is None or self._http.is_closed: self._http = httpx.AsyncClient( timeout=httpx.Timeout(_TIMEOUT_OLLAMA, connect=10.0) ) return self._http async def review_pr( self, pr_id: str, repo: str, title: str, diff: str, ) -> dict[str, Any] | None: """ 審查 PR diff,回傳審查結果 dict {review_text, issues_count, model, provider} """ async with _SEMAPHORE: cache_key = f"pr_review:{repo}:{pr_id}" # 快取命中 try: from src.core.redis_client import get_redis redis = await get_redis() if redis: cached = await redis.get(cache_key) if cached: logger.info("pr_review_cache_hit", pr_id=pr_id) import json return json.loads(cached) except Exception: redis = None diff_size = len(diff.encode()) allow_cloud_fallback = settings.LOCAL_CODE_REVIEW_ALLOW_GEMINI_FALLBACK if diff_size > _MAX_DIFF_BYTES and allow_cloud_fallback: result = await self._review_with_gemini(pr_id, repo, title, diff) else: if diff_size > _MAX_DIFF_BYTES: logger.info( "pr_review_large_diff_using_ollama_truncated", pr_id=pr_id, diff_size_bytes=diff_size, cloud_fallback_enabled=allow_cloud_fallback, ) result = await self._review_with_ollama(pr_id, repo, title, diff) if result is None and allow_cloud_fallback: result = await self._review_with_gemini(pr_id, repo, title, diff) if result is None: result = self._cloud_fallback_disabled_result(pr_id, repo, title) result["diff_size_bytes"] = diff_size # 寫入快取 if redis: try: import json await redis.set(cache_key, json.dumps(result, ensure_ascii=False), ex=_CACHE_TTL) except Exception: pass # 寫入 DB await self._save_to_db(pr_id, repo, title, diff_size, result) return result async def _review_with_ollama( self, pr_id: str, repo: str, title: str, diff: str ) -> dict[str, Any] | None: prompt = ( f"你是資深程式審查員,請用繁體中文審查以下 Pull Request。\n" f"PR: {repo}#{pr_id} — {title}\n\n" "請找出:1) 潛在 Bug 或邏輯錯誤 2) 安全問題 3) 效能問題 4) 代碼風格問題\n" "格式:每個問題獨立一行,以「⚠️」開頭。如果沒有問題,說「✅ 程式碼品質良好」\n\n" f"=== Diff ===\n{diff[:40000]}\n=== 結束 ===" ) http = await self._get_http() for endpoint in resolve_ollama_order("code_review"): if not endpoint.url: continue try: resp = await http.post( f"{endpoint.url}/api/generate", json={ "model": _MODEL_OLLAMA, "prompt": prompt, "stream": False, "options": {"num_predict": 1024, "temperature": 0.1}, }, ) if resp.status_code == 200: text = resp.json().get("response", "").strip() issues = text.count("⚠️") logger.info( "pr_review_ollama_done", pr_id=pr_id, issues=issues, provider=endpoint.provider_name, ) return { "review_text": text, "issues_count": issues, "model": _MODEL_OLLAMA, "provider": endpoint.provider_name, } logger.warning( "pr_review_ollama_http_error", pr_id=pr_id, provider=endpoint.provider_name, status=resp.status_code, ) except httpx.TimeoutException: logger.warning( "pr_review_ollama_timeout", pr_id=pr_id, provider=endpoint.provider_name, ) except Exception as e: logger.error( "pr_review_ollama_failed", pr_id=pr_id, provider=endpoint.provider_name, error=str(e), ) return None async def _review_with_gemini( self, pr_id: str, repo: str, title: str, diff: str ) -> dict[str, Any] | None: if not settings.LOCAL_CODE_REVIEW_ALLOW_GEMINI_FALLBACK: logger.warning( "pr_review_gemini_fallback_disabled", pr_id=pr_id, repo=repo, ) return None try: from src.services.openclaw import get_openclaw openclaw = get_openclaw() prompt = ( f"PR Code Review: {repo}#{pr_id} — {title}\n" "繁體中文,找出 Bug/安全/效能/風格問題,每問題以⚠️開頭\n\n" f"Diff:\n{diff[:60000]}" ) # 直接呼叫 Gemini result = await openclaw._call_gemini(prompt) if result and result[0]: text = result[0] issues = text.count("⚠️") return {"review_text": text, "issues_count": issues, "model": "gemini", "provider": "gemini"} except Exception as e: logger.error("pr_review_gemini_failed", pr_id=pr_id, error=str(e)) return None def _cloud_fallback_disabled_result( self, pr_id: str, repo: str, title: str, ) -> dict[str, Any]: logger.warning( "pr_review_cloud_fallback_skipped", pr_id=pr_id, repo=repo, title=title, reason="LOCAL_CODE_REVIEW_ALLOW_GEMINI_FALLBACK=false", ) return { "review_text": ( "⚠️ Code Review:本地 Ollama 審查未完成," "已依成本策略跳過 Gemini fallback。" ), "issues_count": 1, "model": _MODEL_OLLAMA, "provider": "ollama_unavailable", "cloud_fallback_skipped": True, } async def _save_to_db( self, pr_id: str, repo: str, title: str, diff_size: int, result: dict ) -> None: try: from sqlalchemy import text from src.db.base import get_db_context async with get_db_context() as db: await db.execute( text(""" INSERT INTO pr_reviews (pr_id, repo, title, diff_size_bytes, model, provider, review_text, issues_count) VALUES (:pr_id, :repo, :title, :diff_size, :model, :provider, :review_text, :issues_count) """), { "pr_id": pr_id, "repo": repo, "title": title, "diff_size": diff_size, "model": result.get("model", "unknown"), "provider": result.get("provider", "unknown"), "review_text": result.get("review_text", ""), "issues_count": result.get("issues_count", 0), }, ) except Exception as e: logger.warning("pr_review_db_save_failed", error=str(e)) async def review_push( self, repo_name: str, branch: str, commit_summary: str, files_summary: str, ) -> dict[str, Any] | None: """ 審查 push event(非 PR),用 qwen2.5-coder:7b 快速掃描 2026-04-10 Claude Sonnet 4.6: Phase 32 ADR-067 — gitea_webhook_service 呼叫 """ async with _SEMAPHORE: prompt = ( f"你是資深程式審查員,請用繁體中文快速審查以下 Git Push。\n" f"Repository: {repo_name} Branch: {branch}\n\n" f"=== Commits ===\n{commit_summary}\n\n" f"=== Changed Files ===\n{files_summary}\n\n" "請簡要說明:1) 有無明顯安全風險 2) 有無破壞性變更 3) 整體品質評估\n" "格式:每個問題以「⚠️」開頭,如無問題說「✅ Push 品質正常」\n" "5 行以內,繁體中文。" ) http = await self._get_http() for endpoint in resolve_ollama_order("code_review"): if not endpoint.url: continue try: resp = await http.post( f"{endpoint.url}/api/generate", json={ "model": _MODEL_OLLAMA, "prompt": prompt, "stream": False, "options": {"num_predict": 512, "temperature": 0.1}, }, ) if resp.status_code == 200: text = resp.json().get("response", "").strip() issues = text.count("⚠️") logger.info( "push_review_ollama_done", repo=repo_name, branch=branch, issues=issues, provider=endpoint.provider_name, ) return { "review_text": text, "issues_count": issues, "model": _MODEL_OLLAMA, "provider": endpoint.provider_name, } logger.warning( "push_review_ollama_http_error", repo=repo_name, provider=endpoint.provider_name, status=resp.status_code, ) except httpx.TimeoutException: logger.warning( "push_review_ollama_timeout", repo=repo_name, provider=endpoint.provider_name, ) except Exception as e: logger.error( "push_review_ollama_failed", repo=repo_name, provider=endpoint.provider_name, error=str(e), ) return None async def close(self) -> None: if self._http and not self._http.is_closed: await self._http.aclose() _instance: LocalCodeReviewService | None = None def get_local_code_review_service() -> LocalCodeReviewService: global _instance if _instance is None: _instance = LocalCodeReviewService() return _instance def set_local_code_review_service(svc: LocalCodeReviewService) -> None: global _instance _instance = svc