feat(webhook): Task 1+2 — config + service GitHub→Gitea 遷移 (ADR-059)

- config.py: GITHUB_WEBHOOK_SECRET/ALLOWED_REPOS → GITEA_*
- 新增 gitea_webhook_service.py: PR/Push review only, 移除 CI diagnosis
- 移除 CIFailureDiagnosis, diagnose_ci_failure, _call_openclaw_ci_diagnosis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-05 14:33:58 +08:00
parent 84f1f9f021
commit 6777532534

View File

@@ -0,0 +1,684 @@
"""
AWOOOI API - Gitea Webhook Service
====================================
ADR-059: GitHub → Gitea Webhook 遷移
整合流程:
1. Gitea Webhook (PR/Push) → AWOOOI API
2. HMAC-SHA256 簽章驗證 (X-Gitea-Signature)
3. 解析 PR diff / Push commits
4. 呼叫 OpenClaw 進行 AI 代碼審查
5. 儲存審查結果到 Redis
6. 發送 Telegram 通知
7. (可選) 建立 Approval 等待人工確認
支援事件:
- pull_request: PR 代碼審查
- push: 主分支推送審查
版本: v1.0
最後修改: 2026-04-05 (台北時區)
修改者: Claude Code (ADR-059 GitHub → Gitea 遷移)
"""
import json
import uuid
from typing import Protocol
import structlog
from pydantic import BaseModel, Field
from src.core.config import settings
from src.core.redis_client import get_redis
from src.utils.timezone import now_taipei_iso
logger = structlog.get_logger(__name__)
# Redis TTL: 7 天
GITEA_REVIEW_TTL_SECONDS = 7 * 24 * 60 * 60
# =============================================================================
# Result Models (Service 層數據契約)
# =============================================================================
class CodeReviewResult(BaseModel):
"""AI 代碼審查結果"""
summary: str = Field(..., description="審查摘要")
issues: list[dict] = Field(default=[], description="發現的問題列表")
suggestions: list[dict] = Field(default=[], description="改進建議")
security_concerns: list[str] = Field(default=[], description="安全疑慮")
quality_score: float = Field(..., ge=0, le=100, description="代碼品質分數 0-100")
analyzed_by: str = Field(..., description="分析模型 (ollama/claude)")
confidence: float = Field(..., ge=0, le=1, description="分析信心度 0-1")
# =============================================================================
# Repository Interface & Implementation
# =============================================================================
class IGiteaReviewRepository(Protocol):
"""Gitea Review Repository Interface"""
async def save_review(self, review_id: str, review_data: dict) -> bool:
"""儲存審查結果"""
...
async def get_review(self, review_id: str) -> dict | None:
"""取得審查結果"""
...
class GiteaReviewRedisRepository:
"""Redis 實作的 Gitea Review Repository"""
KEY_PREFIX = "gitea_review:"
async def save_review(self, review_id: str, review_data: dict) -> bool:
"""
儲存審查結果到 Redis
Args:
review_id: 審查 ID
review_data: 審查結果資料
Returns:
bool: 儲存是否成功
"""
try:
redis_client = get_redis()
key = f"{self.KEY_PREFIX}{review_id}"
await redis_client.set(
key,
json.dumps(review_data, ensure_ascii=False),
ex=GITEA_REVIEW_TTL_SECONDS,
)
logger.debug("github_review_saved", review_id=review_id)
return True
except Exception as e:
logger.error("github_review_save_failed", review_id=review_id, error=str(e))
return False
async def get_review(self, review_id: str) -> dict | None:
"""
取得審查結果
Args:
review_id: 審查 ID
Returns:
審查結果資料,或 None
"""
try:
redis_client = get_redis()
key = f"{self.KEY_PREFIX}{review_id}"
result = await redis_client.get(key)
if result:
return json.loads(result)
return None
except Exception as e:
logger.error("github_review_get_failed", review_id=review_id, error=str(e))
return None
# =============================================================================
# Service
# =============================================================================
class GiteaWebhookService:
"""
GitHub Webhook 服務
封裝審查結果的儲存、查詢以及全部業務協調流程:
- PR 代碼審查 (review_pull_request)
- Push 代碼審查 (review_push)
- CI 失敗診斷 (diagnose_ci_failure)
- OpenClaw 呼叫封裝
- Telegram 通知
- Approval 建立
"""
def __init__(self, repository: IGiteaReviewRepository | None = None):
self._repository = repository or GiteaReviewRedisRepository()
# ------------------------------------------------------------------
# Redis CRUD
# ------------------------------------------------------------------
async def save_review_result(
self,
review_id: str,
review_data: dict,
ttl: int | None = None,
) -> bool:
"""儲存審查結果 (支援自訂 TTL)"""
if ttl is not None and ttl != GITEA_REVIEW_TTL_SECONDS:
# 直接寫 Redis 以使用自訂 TTL
try:
redis_client = get_redis()
key = f"{self._repository.KEY_PREFIX}{review_id}" # type: ignore[attr-defined]
await redis_client.set(
key,
json.dumps(review_data, ensure_ascii=False),
ex=ttl,
)
logger.debug("github_review_saved_custom_ttl", review_id=review_id, ttl=ttl)
return True
except Exception as e:
logger.error("github_review_save_failed", review_id=review_id, error=str(e))
return False
return await self._repository.save_review(review_id, review_data)
async def get_review_result(self, review_id: str) -> dict | None:
"""取得審查結果"""
return await self._repository.get_review(review_id)
# ------------------------------------------------------------------
# Internal helpers: OpenClaw
# ------------------------------------------------------------------
async def _fetch_pr_diff(self, diff_url: str) -> str:
"""取得 PR diff 內容 (委派給 GitHubApiService)"""
from src.services.github_api_service import get_github_api_service
service = get_github_api_service()
return await service.fetch_pr_diff(diff_url)
async def _call_openclaw_code_review(
self,
repo_name: str,
pr_title: str,
pr_body: str,
diff_content: str,
changed_files: int,
additions: int,
deletions: int,
) -> CodeReviewResult | None:
"""
呼叫 OpenClaw 進行 PR 代碼審查
優先使用 Ollama (本地,零成本)
Fallback: Claude (大型 PR)
Phase 22 P0 修復: 使用 OpenClawHttpService (2026-03-31)
"""
try:
from src.services.openclaw_http_service import get_openclaw_http_service
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 data:
return CodeReviewResult(**data)
return None
except Exception as e:
logger.exception("openclaw_code_review_error", error=str(e))
return None
async def _call_openclaw_push_review(
self,
repo_name: str,
ref: str,
commits: list[dict],
files_changed: dict,
) -> CodeReviewResult | None:
"""
呼叫 OpenClaw 進行 Push 代碼審查
Phase 22 P0 修復: 使用 OpenClawHttpService (2026-03-31)
"""
try:
from src.services.openclaw_http_service import get_openclaw_http_service
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 data:
return CodeReviewResult(**data)
return None
except Exception as e:
logger.exception("openclaw_push_review_error", error=str(e))
return None
# ------------------------------------------------------------------
# Internal helpers: persist
# ------------------------------------------------------------------
async def _save_review_with_analysis(
self,
review_id: str,
event_type: str,
repo: str,
target: str,
analysis: CodeReviewResult | None,
metadata: dict,
) -> None:
"""
組裝並儲存代碼審查結果到 Redis (透過 Service)
Key: gitea_review:{review_id}
TTL: 7 天
"""
result = {
"review_id": review_id,
"event_type": event_type,
"repo": repo,
"target": target,
"created_at": now_taipei_iso(),
"analysis": analysis.model_dump() if analysis else None,
"metadata": metadata,
}
success = await self._repository.save_review(review_id, result)
if success:
logger.info("github_review_saved", review_id=review_id, ttl_days=7)
else:
logger.error("github_review_save_failed", review_id=review_id)
# ------------------------------------------------------------------
# Internal helpers: Telegram
# ------------------------------------------------------------------
async def _send_gitea_telegram_alert(
self,
review_id: str,
event_type: str,
repo: str,
target: str,
url: str,
author: str,
analysis: CodeReviewResult | None,
) -> None:
"""
發送 GitHub 審查告警到 Telegram
格式:
═══════════════════════════
🔍 GITEA CODE REVIEW
═══════════════════════════
📦 repo/name
🔀 PR #123: Feature title
👤 @author
───────────────────────────
📊 品質分數: 85/100
⚠️ 發現 2 個問題
🔐 1 個安全疑慮
───────────────────────────
🧠 AI 摘要:
「代碼品質良好,但建議...」
───────────────────────────
[ 🔗 查看 PR ] [ 📋 詳情 ]
"""
try:
from src.services.telegram_gateway import get_telegram_gateway
telegram = get_telegram_gateway()
# 檢查是否有設定 Bot Token
if not settings.OPENCLAW_TG_BOT_TOKEN:
logger.debug("github_telegram_skipped", reason="Bot token not configured")
return
await telegram.initialize()
# 構建訊息
quality_emoji = (
"🟢" if analysis and analysis.quality_score >= 80
else "🟡" if analysis and analysis.quality_score >= 60
else "🔴"
)
message_lines = [
"═══════════════════════════",
"🔍 GITEA CODE REVIEW",
"═══════════════════════════",
f"📦 {repo}",
f"🔀 {target}",
f"👤 @{author}",
"───────────────────────────",
]
if analysis:
message_lines.extend([
f"{quality_emoji} 品質分數: {analysis.quality_score:.0f}/100",
])
if analysis.issues:
message_lines.append(f"⚠️ 發現 {len(analysis.issues)} 個問題")
if analysis.security_concerns:
message_lines.append(f"🔐 {len(analysis.security_concerns)} 個安全疑慮")
message_lines.extend([
"───────────────────────────",
"🧠 AI 摘要:",
f"{analysis.summary[:150]}",
])
else:
message_lines.append("❌ AI 分析失敗")
message_lines.extend([
"───────────────────────────",
f"🔗 {url}",
f"📋 Review ID: {review_id}",
])
message = "\n".join(message_lines)
# 發送訊息 (使用 send_notification 而非 send_message)
await telegram.send_notification(message)
logger.info("github_telegram_sent", review_id=review_id, repo=repo, event_type=event_type)
except Exception as e:
logger.exception("github_telegram_failed", error=str(e))
# ------------------------------------------------------------------
# Internal helpers: Approval
# ------------------------------------------------------------------
async def _create_gitea_approval(
self,
review_id: str,
repo: str,
target: str,
url: str,
analysis: CodeReviewResult,
) -> str:
"""
為有安全疑慮的 PR 建立 Approval 記錄
Returns:
str: Approval ID
"""
try:
from src.models.approval import (
ApprovalRequestCreate,
BlastRadius,
DataImpact,
RiskLevel,
)
from src.services.approval_db import get_approval_service
approval_service = get_approval_service()
# 決定風險等級
if len(analysis.security_concerns) > 2 or analysis.quality_score < 50:
risk_level = RiskLevel.CRITICAL
elif analysis.security_concerns or analysis.quality_score < 70:
risk_level = RiskLevel.HIGH
else:
risk_level = RiskLevel.MEDIUM
# P1-2 修正: 欄位對齊 ApprovalRequestBase (2026-03-29)
root_cause = f"Code review found security concerns in {target}"
suggestion = f"Review {len(analysis.security_concerns)} security concern(s): {', '.join(analysis.security_concerns[:3])}"
approval_request = ApprovalRequestCreate(
action=f"Code Review Security: {repo}",
description=f"Root Cause: {root_cause}\nSuggestion: {suggestion}",
risk_level=risk_level,
blast_radius=BlastRadius(
affected_pods=1,
estimated_downtime="0",
related_services=[repo],
data_impact=DataImpact.READ_ONLY,
),
dry_run_checks=[],
requested_by="gitea-webhook",
metadata={
"source": "github",
"alert_type": "code_review_security",
"target_resource": repo,
"namespace": "github",
"github_review_id": review_id,
"target": target,
"url": url,
"quality_score": analysis.quality_score,
"security_concerns": analysis.security_concerns,
"issues_count": len(analysis.issues),
"llm_provider": analysis.analyzed_by,
"llm_confidence": analysis.confidence,
},
)
# 創建 Approval
approval_id = str(uuid.uuid4())
await approval_service.create_approval(
approval_id=approval_id,
request=approval_request,
)
logger.info(
"github_approval_created",
approval_id=approval_id,
review_id=review_id,
risk_level=risk_level.value,
)
return approval_id
except Exception as e:
logger.exception("github_approval_creation_failed", error=str(e))
return f"temp-{uuid.uuid4().hex[:8]}"
# ------------------------------------------------------------------
# Public: Orchestration (Background Tasks)
# ------------------------------------------------------------------
async def review_pull_request(
self,
repo, # GitHubRepository
pr, # GitHubPullRequest
sender, # GitHubUser
review_id: str,
action: str,
) -> None:
"""
背景任務: PR 代碼審查
1. 取得 PR diff
2. 呼叫 OpenClaw 分析
3. 儲存結果到 Redis
4. 發送 Telegram 通知
5. 建立 Approval (可選)
"""
try:
logger.info(
"github_pr_review_started",
review_id=review_id,
repo=repo.full_name,
pr_number=pr.number,
sender=sender.login,
)
# 1. 取得 PR diff
diff_content = await self._fetch_pr_diff(pr.diff_url)
# 2. 呼叫 OpenClaw 進行代碼審查
analysis = await self._call_openclaw_code_review(
repo_name=repo.full_name,
pr_title=pr.title,
pr_body=pr.body or "",
diff_content=diff_content,
changed_files=pr.changed_files,
additions=pr.additions,
deletions=pr.deletions,
)
# 3. 儲存結果到 Redis
await self._save_review_with_analysis(
review_id=review_id,
event_type="pull_request",
repo=repo.full_name,
target=f"PR #{pr.number}",
analysis=analysis,
metadata={
"pr_number": pr.number,
"pr_title": pr.title,
"pr_url": pr.html_url,
"author": pr.user.login,
"action": action,
"changed_files": pr.changed_files,
"additions": pr.additions,
"deletions": pr.deletions,
},
)
# 4. 發送 Telegram 通知
await self._send_gitea_telegram_alert(
review_id=review_id,
event_type="pull_request",
repo=repo.full_name,
target=f"PR #{pr.number}: {pr.title[:50]}",
url=pr.html_url,
author=pr.user.login,
analysis=analysis,
)
# 5. 如果有安全疑慮,建立 Approval
if analysis and analysis.security_concerns:
await self._create_gitea_approval(
review_id=review_id,
repo=repo.full_name,
target=f"PR #{pr.number}",
url=pr.html_url,
analysis=analysis,
)
logger.info(
"github_pr_review_completed",
review_id=review_id,
quality_score=analysis.quality_score if analysis else None,
has_security_concerns=bool(analysis and analysis.security_concerns),
)
except Exception as e:
logger.exception(
"github_pr_review_failed",
review_id=review_id,
error=str(e),
)
async def review_push(
self,
repo, # GitHubRepository
commits: list, # list[GitHubCommit]
sender, # GitHubUser
review_id: str,
ref: str,
before_sha: str | None,
after_sha: str | None,
) -> None:
"""
背景任務: Push 代碼審查
1. 整理 commit 資訊
2. 呼叫 OpenClaw 分析
3. 儲存結果到 Redis
4. 發送 Telegram 通知 (只有發現問題時才通知)
"""
try:
logger.info(
"github_push_review_started",
review_id=review_id,
repo=repo.full_name,
commit_count=len(commits),
)
# 1. 整理 commit 資訊
commit_summary = []
all_files: dict[str, list] = {"added": [], "modified": [], "removed": []}
for commit in commits:
commit_summary.append({
"sha": commit.id[:8],
"message": commit.message[:100],
"author": commit.author.get("name", "unknown"),
})
all_files["added"].extend(commit.added)
all_files["modified"].extend(commit.modified)
all_files["removed"].extend(commit.removed)
# 2. 呼叫 OpenClaw 進行代碼審查 (Push 版)
analysis = await self._call_openclaw_push_review(
repo_name=repo.full_name,
ref=ref,
commits=commit_summary,
files_changed=all_files,
)
# 3. 儲存結果到 Redis
await self._save_review_with_analysis(
review_id=review_id,
event_type="push",
repo=repo.full_name,
target=f"push to {ref.split('/')[-1]}",
analysis=analysis,
metadata={
"ref": ref,
"before_sha": before_sha,
"after_sha": after_sha,
"commit_count": len(commits),
"pusher": sender.login,
"files": all_files,
},
)
# 4. 發送 Telegram 通知 (只有發現問題時才通知)
if analysis and (
analysis.issues
or analysis.security_concerns
or analysis.quality_score < 70
):
await self._send_gitea_telegram_alert(
review_id=review_id,
event_type="push",
repo=repo.full_name,
target=f"push to {ref.split('/')[-1]} ({len(commits)} commits)",
url=repo.html_url,
author=sender.login,
analysis=analysis,
)
logger.info(
"github_push_review_completed",
review_id=review_id,
quality_score=analysis.quality_score if analysis else None,
)
except Exception as e:
logger.exception(
"github_push_review_failed",
review_id=review_id,
error=str(e),
)
# =============================================================================
# Singleton
# =============================================================================
# 單例
_service: GiteaWebhookService | None = None
def get_gitea_webhook_service() -> GiteaWebhookService:
"""取得 GiteaWebhookService 單例"""
global _service
if _service is None:
_service = GiteaWebhookService()
return _service