""" 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} TYPE-1 | Gitea Code Review", "──────────────────────", f"├─ 倉庫: {repo}", f"├─ 目標: {target}", 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"└─ Review #{review_id}", ]) 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