Files
awoooi/apps/api/src/services/gitea_webhook_service.py
Your Name 61f5a6a419
Some checks failed
CD Pipeline / tests (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled
fix(telegram): route alerts to SRE war room
2026-04-30 15:01:23 +08:00

710 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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("gitea_review_saved", review_id=review_id)
return True
except Exception as e:
logger.error("gitea_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("gitea_review_get_failed", review_id=review_id, error=str(e))
return None
# =============================================================================
# Service
# =============================================================================
class GiteaWebhookService:
"""
Gitea Webhook 服務
封裝審查結果的儲存、查詢以及全部業務協調流程:
- PR 代碼審查 (review_pull_request)
- Push 代碼審查 (review_push)
- 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("gitea_review_saved_custom_ttl", review_id=review_id, ttl=ttl)
return True
except Exception as e:
logger.error("gitea_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 內容 (直接 HTTP GET Gitea diff URL)"""
try:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(diff_url)
if response.status_code == 200:
content = response.text
if len(content) > 50000:
content = content[:50000] + "\n... (truncated)"
return content
logger.warning("gitea_diff_fetch_failed", url=diff_url, status=response.status_code)
return ""
except Exception as e:
logger.warning("gitea_diff_fetch_error", url=diff_url, error=str(e))
return ""
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.local_code_review_service import get_local_code_review_service
svc = get_local_code_review_service()
pr_id = f"{repo_name}-{pr_title[:20]}"
result = await svc.review_pr(
pr_id=pr_id,
repo=repo_name,
title=pr_title,
diff=diff_content,
)
if result and isinstance(result, dict):
review_text = result.get("review_text", "")
issues_count = result.get("issues_count", 0)
return CodeReviewResult(
summary=review_text[:300] if review_text else "PR review completed",
issues=[{"text": line} for line in review_text.split("\n") if "⚠️" in line],
suggestions=[],
security_concerns=[],
quality_score=max(0.0, 100.0 - issues_count * 15),
analyzed_by=result.get("model", "ollama"),
confidence=0.7,
)
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:
"""
Phase 32 ADR-067: Ollama qwen2.5-coder:7b push 代碼審查
2026-04-10 Claude Sonnet 4.6: OpenClaw 無 push-review 端點 → 改用 Ollama 本地推理
"""
try:
from src.services.local_code_review_service import get_local_code_review_service
svc = get_local_code_review_service()
branch = ref.split("/")[-1]
commit_msgs = "\n".join(f'- {c["sha"]}: {c["message"]}' for c in commits[:5])
added = files_changed.get("added", [])
modified = files_changed.get("modified", [])
removed = files_changed.get("removed", [])
files_summary = (
f"Added: {', '.join(added[:5]) or ''}\n"
f"Modified: {', '.join(modified[:5]) or ''}\n"
f"Removed: {', '.join(removed[:5]) or ''}"
)
result = await svc.review_push(
repo_name=repo_name,
branch=branch,
commit_summary=commit_msgs,
files_summary=files_summary,
)
# local_code_review_service 回傳 dict需轉成 CodeReviewResult
if result and isinstance(result, dict):
issues_count = result.get("issues_count", 0)
review_text = result.get("review_text", "")
return CodeReviewResult(
summary=review_text[:300] if review_text else "Push review completed",
issues=[{"text": line} for line in review_text.split("\n") if "⚠️" in line],
suggestions=[],
security_concerns=[],
quality_score=max(0.0, 100.0 - issues_count * 15),
analyzed_by=result.get("model", "ollama"),
confidence=0.7,
)
return result # type: ignore[return-value] # None case
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(),
# 2026-04-12 ogt: _call_openclaw_push_review 回傳 dict無 model_dump()
"analysis": analysis.model_dump() if hasattr(analysis, "model_dump") else analysis,
"metadata": metadata,
}
success = await self._repository.save_review(review_id, result)
if success:
logger.info("gitea_review_saved", review_id=review_id, ttl_days=7)
else:
logger.error("gitea_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:
"""
發送 Gitea 審查告警到 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("gitea_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 "🔴"
)
# 2026-04-15 ogt: 改為 ADR-075 TYPE-1 格式(禁止 ═══ raw 格式)
message_lines = [
f"{quality_emoji} <b>TYPE-1 | Gitea Code Review</b>",
"──────────────────────",
f"├─ 倉庫: <code>{repo}</code>",
f"├─ 目標: <code>{target}</code>",
f"├─ 作者: @{author}",
]
if analysis:
message_lines.append(f"├─ 品質分數: {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.append(f"├─ AI 摘要: 「{analysis.summary[:150]}")
else:
message_lines.append("├─ AI 分析失敗")
message_lines.extend([
f"└─ <a href=\"{url}\">Review #{review_id}</a>",
])
message = "\n".join(message_lines)
await telegram.send_alert_notification(message)
logger.info("gitea_telegram_sent", review_id=review_id, repo=repo, event_type=event_type)
except Exception as e:
logger.exception("gitea_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": "gitea",
"alert_type": "code_review_security",
"target_resource": repo,
"namespace": "gitea",
"gitea_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(
"gitea_approval_created",
approval_id=approval_id,
review_id=review_id,
risk_level=risk_level.value,
)
return approval_id
except Exception as e:
logger.exception("gitea_approval_creation_failed", error=str(e))
return f"temp-{uuid.uuid4().hex[:8]}"
# ------------------------------------------------------------------
# Public: Orchestration (Background Tasks)
# ------------------------------------------------------------------
async def review_pull_request(
self,
repo, # GiteaRepository
pr, # GiteaPullRequest
sender, # GiteaUser
review_id: str,
action: str,
) -> None:
"""
背景任務: PR 代碼審查
1. 取得 PR diff
2. 呼叫 OpenClaw 分析
3. 儲存結果到 Redis
4. 發送 Telegram 通知
5. 建立 Approval (可選)
"""
try:
logger.info(
"gitea_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(
"gitea_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(
"gitea_pr_review_failed",
review_id=review_id,
error=str(e),
)
async def review_push(
self,
repo, # GiteaRepository
commits: list, # list[GiteaCommit]
sender, # GiteaUser
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(
"gitea_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(
"gitea_push_review_completed",
review_id=review_id,
quality_score=analysis.quality_score if analysis else None,
)
except Exception as e:
logger.exception(
"gitea_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