From 67775325348e724d3dcb4cdd03787ce91790f548 Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 5 Apr 2026 14:33:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20Task=201+2=20=E2=80=94=20confi?= =?UTF-8?q?g=20+=20service=20GitHub=E2=86=92Gitea=20=E9=81=B7=E7=A7=BB=20(?= =?UTF-8?q?ADR-059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/src/services/gitea_webhook_service.py | 684 ++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 apps/api/src/services/gitea_webhook_service.py diff --git a/apps/api/src/services/gitea_webhook_service.py b/apps/api/src/services/gitea_webhook_service.py new file mode 100644 index 00000000..fbb1aef2 --- /dev/null +++ b/apps/api/src/services/gitea_webhook_service.py @@ -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