343 lines
13 KiB
Python
343 lines
13 KiB
Python
"""
|
||
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
|