diff --git a/apps/api/src/api/v1/github_webhook.py b/apps/api/src/api/v1/github_webhook.py new file mode 100644 index 000000000..92466df85 --- /dev/null +++ b/apps/api/src/api/v1/github_webhook.py @@ -0,0 +1,1050 @@ +""" +AWOOOI API - GitHub Webhook Handler +==================================== +Phase 13.1: GitHub PR/Push → OpenClaw AI 代碼審查整合 + +整合流程: +1. GitHub Webhook (PR/Push) → AWOOOI API +2. HMAC-SHA256 簽章驗證 (X-Hub-Signature-256) +3. 解析 PR diff / Push commits +4. 呼叫 OpenClaw 進行 AI 代碼審查 +5. 儲存審查結果到 Redis +6. 發送 Telegram 通知 +7. (可選) 回寫 GitHub PR Comment + +安全要求 (feedback_openclaw_security.md): +- HMAC 簽章驗證 (X-Hub-Signature-256) +- Webhook Secret 存放於 K8s Secret +- Rate limiting 防止 DoS +- 倉庫白名單驗證 + +🔴 HARD RULE: 時間顯示使用 Asia/Taipei (UTC+8) +""" + +import hashlib +import hmac +import json +import uuid +from typing import Literal + +import httpx +from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status +from pydantic import BaseModel, Field + +from src.core.config import settings +from src.core.logging import get_logger +from src.models.approval import ( + ApprovalRequestCreate, + BlastRadius, + DataImpact, + DryRunCheck, + RiskLevel, +) +from src.services.approval_db import get_approval_service +from src.services.github_webhook_service import get_github_webhook_service +from src.services.telegram_gateway import get_telegram_gateway +from src.utils.timezone import now_taipei_iso + +logger = get_logger("awoooi.github_webhook") + +router = APIRouter(prefix="/webhooks/github", tags=["GitHub Webhook"]) + +# ============================================================================= +# Constants +# ============================================================================= + +# OpenClaw 配置 (使用 settings 中的 OPENCLAW_URL) +OPENCLAW_URL = settings.OPENCLAW_URL + +# GitHub Review 結果 Redis TTL: 7 天 (秒) +GITHUB_REVIEW_TTL_SECONDS = 7 * 24 * 60 * 60 + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + +class GitHubUser(BaseModel): + """GitHub 使用者""" + login: str + id: int + avatar_url: str | None = None + + +class GitHubRepository(BaseModel): + """GitHub 倉庫""" + id: int + name: str + full_name: str + private: bool = False + html_url: str + + +class GitHubPullRequest(BaseModel): + """GitHub PR 資訊""" + id: int + number: int + title: str + body: str | None = None + state: str # open, closed + html_url: str + diff_url: str + user: GitHubUser + head: dict # head branch info + base: dict # base branch info + additions: int = 0 + deletions: int = 0 + changed_files: int = 0 + + +class GitHubCommit(BaseModel): + """GitHub Commit 資訊""" + id: str # SHA + message: str + timestamp: str + url: str + author: dict + added: list[str] = [] + removed: list[str] = [] + modified: list[str] = [] + + +class GitHubWebhookPayload(BaseModel): + """GitHub Webhook Payload (通用)""" + action: str | None = None # PR: opened, synchronize, etc. + repository: GitHubRepository + sender: GitHubUser + # PR 事件 + pull_request: GitHubPullRequest | None = None + # Push 事件 + ref: str | None = None # refs/heads/main + before: str | None = None # previous commit SHA + after: str | None = None # current commit SHA + commits: list[GitHubCommit] | None = None + pusher: dict | None = None + + +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") + + +class GitHubWebhookResponse(BaseModel): + """Webhook 回應""" + status: Literal["accepted", "ignored", "error"] + message: str + event_type: str | None = None + review_id: str | None = None + + +# ============================================================================= +# HMAC Signature Verification (CISO 安全要求) +# ============================================================================= + +class GitHubSignatureError(Exception): + """GitHub 簽章驗證失敗""" + pass + + +async def verify_github_signature( + request: Request, + x_hub_signature_256: str | None, +) -> bool: + """ + 驗證 GitHub Webhook 請求的 HMAC-SHA256 簽章 + + CISO 安全要求: + - 所有 GitHub Webhook 必須攜帶 X-Hub-Signature-256 Header + - 簽章格式: sha256= + - 使用 GITHUB_WEBHOOK_SECRET 進行驗證 + + 安全鐵律 (Fail-Closed): + - 生產環境: Secret 未設定 → 直接拒絕 + - 開發環境: 可跳過驗證 (僅供本地測試) + + Args: + request: FastAPI Request 物件 + x_hub_signature_256: X-Hub-Signature-256 Header 值 + + Returns: + bool: 驗證是否通過 + + Raises: + GitHubSignatureError: 簽章驗證失敗 + """ + # ========================================================================== + # Fail-Closed 安全策略 (CISO 要求) + # ========================================================================== + if not settings.GITHUB_WEBHOOK_SECRET: + # 生產環境: 強制拒絕 (Fail-Closed) + if settings.ENVIRONMENT == "prod": + logger.critical( + "github_webhook_secret_missing_in_production", + environment=settings.ENVIRONMENT, + message="CRITICAL: GITHUB_WEBHOOK_SECRET missing in production!", + ) + raise GitHubSignatureError( + "Critical: GITHUB_WEBHOOK_SECRET missing in production environment" + ) + + # 開發環境: 允許跳過 (僅供本地測試) + logger.warning( + "github_signature_verification_skipped_dev_only", + environment=settings.ENVIRONMENT, + reason="GITHUB_WEBHOOK_SECRET not configured (dev mode only)", + ) + return True + + # 必須提供簽章 + if not x_hub_signature_256: + logger.warning("github_signature_missing") + raise GitHubSignatureError("Missing X-Hub-Signature-256 header") + + # 解析簽章格式 + if not x_hub_signature_256.startswith("sha256="): + raise GitHubSignatureError("Invalid signature format (expected sha256=...)") + + provided_signature = x_hub_signature_256[7:] # 移除 "sha256=" 前綴 + + # 讀取 Request Body + body = await request.body() + + # 計算預期簽章 + expected_signature = hmac.new( + settings.GITHUB_WEBHOOK_SECRET.encode(), + body, + hashlib.sha256, + ).hexdigest() + + # 常數時間比較 (防止計時攻擊) + if not hmac.compare_digest(provided_signature, expected_signature): + logger.warning( + "github_signature_verification_failed", + provided=provided_signature[:16] + "...", + expected=expected_signature[:16] + "...", + ) + raise GitHubSignatureError("Invalid signature") + + logger.info("github_signature_verification_success") + return True + + +def verify_allowed_repo(full_name: str) -> bool: + """ + 驗證倉庫是否在白名單中 + + Args: + full_name: 完整倉庫名稱 (owner/repo) + + Returns: + bool: 是否允許 + """ + allowed_repos = settings.get_github_allowed_repos() + + # 如果白名單為空,開發環境允許所有 + if not allowed_repos: + if settings.ENVIRONMENT == "prod": + logger.warning( + "github_allowed_repos_empty_in_production", + repo=full_name, + message="No allowed repos configured in production", + ) + return False + # 開發環境: 白名單空 = 允許所有 + logger.debug( + "github_repo_allowed_dev_mode", + repo=full_name, + reason="Empty whitelist in dev mode", + ) + return True + + # 檢查是否在白名單中 + is_allowed = full_name in allowed_repos + if not is_allowed: + logger.warning( + "github_repo_not_in_whitelist", + repo=full_name, + allowed_repos=allowed_repos, + ) + return is_allowed + + +# ============================================================================= +# Main Webhook Handler +# ============================================================================= + +@router.post( + "", + response_model=GitHubWebhookResponse, + status_code=status.HTTP_202_ACCEPTED, + summary="GitHub Webhook 接收端點", + description="接收 GitHub PR/Push 事件並觸發 AI 代碼審查", +) +async def handle_github_webhook( + request: Request, + background_tasks: BackgroundTasks, + x_github_event: str | None = Header(None, alias="X-GitHub-Event"), + x_github_delivery: str | None = Header(None, alias="X-GitHub-Delivery"), + x_hub_signature_256: str | None = Header(None, alias="X-Hub-Signature-256"), +): + """ + GitHub Webhook Handler + + 支援事件: + - pull_request (opened, synchronize, reopened) + - push (to default branch) + + 處理流程: + 1. 驗證簽章 + 2. 驗證倉庫白名單 + 3. 解析事件類型 + 4. 背景執行 AI 審查 + """ + try: + # 1. 驗證 HMAC 簽章 + try: + await verify_github_signature(request, x_hub_signature_256) + except GitHubSignatureError as e: + logger.warning("github_webhook_signature_failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) from e + + # 2. 解析 Payload + body = await request.body() + payload_dict = json.loads(body) + payload = GitHubWebhookPayload(**payload_dict) + + # 3. 驗證倉庫白名單 + if not verify_allowed_repo(payload.repository.full_name): + return GitHubWebhookResponse( + status="ignored", + message=f"Repository {payload.repository.full_name} not in whitelist", + event_type=x_github_event, + ) + + # 4. 根據事件類型處理 + logger.info( + "github_webhook_received", + github_event=x_github_event, + delivery_id=x_github_delivery, + repo=payload.repository.full_name, + sender=payload.sender.login, + ) + + # Pull Request 事件 + if x_github_event == "pull_request": + return await handle_pull_request( + payload=payload, + background_tasks=background_tasks, + delivery_id=x_github_delivery, + ) + + # Push 事件 + elif x_github_event == "push": + return await handle_push( + payload=payload, + background_tasks=background_tasks, + delivery_id=x_github_delivery, + ) + + # Ping 事件 (GitHub 測試連線) + elif x_github_event == "ping": + return GitHubWebhookResponse( + status="accepted", + message="Pong! Webhook configured successfully.", + event_type="ping", + ) + + # 其他事件 (忽略) + else: + return GitHubWebhookResponse( + status="ignored", + message=f"Event type '{x_github_event}' not supported", + event_type=x_github_event, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception("github_webhook_processing_failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal error processing webhook", + ) from e + + +# ============================================================================= +# Event Handlers +# ============================================================================= + +async def handle_pull_request( + payload: GitHubWebhookPayload, + background_tasks: BackgroundTasks, + delivery_id: str | None, +) -> GitHubWebhookResponse: + """ + 處理 Pull Request 事件 + + 支援 action: + - opened: 新建 PR + - synchronize: 推送新 commit 到 PR + - reopened: 重新開啟 PR + """ + pr = payload.pull_request + if not pr: + return GitHubWebhookResponse( + status="error", + message="Missing pull_request data", + event_type="pull_request", + ) + + # 只處理需要審查的 action + supported_actions = {"opened", "synchronize", "reopened"} + if payload.action not in supported_actions: + return GitHubWebhookResponse( + status="ignored", + message=f"PR action '{payload.action}' not supported", + event_type="pull_request", + ) + + # 生成審查 ID + review_id = f"gh-pr-{payload.repository.id}-{pr.number}-{uuid.uuid4().hex[:8]}" + + # 背景執行審查 + background_tasks.add_task( + review_pull_request, + repo=payload.repository, + pr=pr, + sender=payload.sender, + review_id=review_id, + action=payload.action, + ) + + logger.info( + "github_pr_review_scheduled", + review_id=review_id, + repo=payload.repository.full_name, + pr_number=pr.number, + pr_title=pr.title[:50], + action=payload.action, + ) + + return GitHubWebhookResponse( + status="accepted", + message=f"PR #{pr.number} review scheduled", + event_type="pull_request", + review_id=review_id, + ) + + +async def handle_push( + payload: GitHubWebhookPayload, + background_tasks: BackgroundTasks, + delivery_id: str | None, +) -> GitHubWebhookResponse: + """ + 處理 Push 事件 + + 只處理 default branch (main/master) 的 push + """ + # 檢查是否推送到主分支 + ref = payload.ref or "" + # 通常是 refs/heads/main 或 refs/heads/master + if not (ref.endswith("/main") or ref.endswith("/master")): + return GitHubWebhookResponse( + status="ignored", + message=f"Push to non-default branch: {ref}", + event_type="push", + ) + + commits = payload.commits or [] + if not commits: + return GitHubWebhookResponse( + status="ignored", + message="No commits in push", + event_type="push", + ) + + # 生成審查 ID + review_id = f"gh-push-{payload.repository.id}-{payload.after[:8]}-{uuid.uuid4().hex[:8]}" + + # 背景執行審查 + background_tasks.add_task( + review_push, + repo=payload.repository, + commits=commits, + sender=payload.sender, + review_id=review_id, + ref=ref, + before_sha=payload.before, + after_sha=payload.after, + ) + + logger.info( + "github_push_review_scheduled", + review_id=review_id, + repo=payload.repository.full_name, + ref=ref, + commit_count=len(commits), + after_sha=payload.after[:8] if payload.after else None, + ) + + return GitHubWebhookResponse( + status="accepted", + message=f"Push with {len(commits)} commit(s) review scheduled", + event_type="push", + review_id=review_id, + ) + + +# ============================================================================= +# Background Tasks: AI Review +# ============================================================================= + +async def review_pull_request( + repo: GitHubRepository, + pr: GitHubPullRequest, + sender: GitHubUser, + review_id: str, + action: str, +): + """ + 背景任務: 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, + ) + + # 1. 取得 PR diff + diff_content = await fetch_pr_diff(pr.diff_url) + + # 2. 呼叫 OpenClaw 進行代碼審查 + analysis = await 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 save_review_result( + 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 send_github_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 create_github_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( + repo: GitHubRepository, + commits: list[GitHubCommit], + sender: GitHubUser, + review_id: str, + ref: str, + before_sha: str | None, + after_sha: str | 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 = {"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 call_openclaw_push_review( + repo_name=repo.full_name, + ref=ref, + commits=commit_summary, + files_changed=all_files, + ) + + # 3. 儲存結果到 Redis + await save_review_result( + 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 send_github_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), + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +async def fetch_pr_diff(diff_url: str) -> str: + """ + 取得 PR diff 內容 + + Args: + diff_url: GitHub diff URL + + Returns: + str: diff 內容 + """ + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + diff_url, + headers={"Accept": "application/vnd.github.v3.diff"}, + ) + if response.status_code == 200: + # 限制 diff 大小 (避免 LLM token 過多) + diff_content = response.text + max_chars = 50000 # 約 12500 tokens + if len(diff_content) > max_chars: + diff_content = diff_content[:max_chars] + "\n... (truncated)" + return diff_content + else: + logger.warning( + "github_diff_fetch_failed", + url=diff_url, + status=response.status_code, + ) + return "" + except Exception as e: + logger.warning("github_diff_fetch_error", error=str(e)) + return "" + + +async def call_openclaw_code_review( + 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) + """ + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{OPENCLAW_URL}/api/v1/analyze/code-review", + json={ + "repo": repo_name, + "pr_title": pr_title, + "pr_body": pr_body, + "diff": diff_content, + "changed_files": changed_files, + "additions": additions, + "deletions": deletions, + "prefer_local": True, # 優先 Ollama + }, + ) + + if response.status_code == 200: + data = response.json() + return CodeReviewResult(**data) + else: + logger.warning( + "openclaw_code_review_failed", + status=response.status_code, + response=response.text[:200], + ) + return None + + except httpx.TimeoutException: + logger.warning("openclaw_code_review_timeout") + return None + except Exception as e: + logger.exception("openclaw_code_review_error", error=str(e)) + return None + + +async def call_openclaw_push_review( + repo_name: str, + ref: str, + commits: list[dict], + files_changed: dict, +) -> CodeReviewResult | None: + """ + 呼叫 OpenClaw 進行 Push 代碼審查 + """ + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{OPENCLAW_URL}/api/v1/analyze/push-review", + json={ + "repo": repo_name, + "ref": ref, + "commits": commits, + "files_changed": files_changed, + "prefer_local": True, + }, + ) + + if response.status_code == 200: + data = response.json() + return CodeReviewResult(**data) + else: + logger.warning( + "openclaw_push_review_failed", + status=response.status_code, + ) + return None + + except httpx.TimeoutException: + logger.warning("openclaw_push_review_timeout") + return None + except Exception as e: + logger.exception("openclaw_push_review_error", error=str(e)) + return None + + +async def save_review_result( + review_id: str, + event_type: str, + repo: str, + target: str, + analysis: CodeReviewResult | None, + metadata: dict, +): + """ + 儲存審查結果到 Redis (透過 Service) + + Key: github_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, + } + + service = get_github_webhook_service() + success = await service.save_review_result(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) + + +async def send_github_telegram_alert( + review_id: str, + event_type: str, + repo: str, + target: str, + url: str, + author: str, + analysis: CodeReviewResult | None, +): + """ + 發送 GitHub 審查告警到 Telegram + + 格式: + ═══════════════════════════ + 🔍 GITHUB CODE REVIEW + ═══════════════════════════ + 📦 repo/name + 🔀 PR #123: Feature title + 👤 @author + ─────────────────────────── + 📊 品質分數: 85/100 + ⚠️ 發現 2 個問題 + 🔐 1 個安全疑慮 + ─────────────────────────── + 🧠 AI 摘要: + 「代碼品質良好,但建議...」 + ─────────────────────────── + [ 🔗 查看 PR ] [ 📋 詳情 ] + """ + try: + 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 = [ + "═══════════════════════════", + "🔍 GITHUB 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) + + # 發送訊息 + await telegram.send_message(message) + + logger.info( + "github_telegram_sent", + review_id=review_id, + repo=repo, + ) + + except Exception as e: + logger.exception("github_telegram_failed", error=str(e)) + + +async def create_github_approval( + review_id: str, + repo: str, + target: str, + url: str, + analysis: CodeReviewResult, +) -> str: + """ + 為有安全疑慮的 PR 建立 Approval 記錄 + + Returns: + str: Approval ID + """ + try: + 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 + + # 組裝 Approval 請求 + approval_request = ApprovalRequestCreate( + source="github", + alert_type="code_review_security", + target_resource=repo, + namespace="github", + risk_level=risk_level, + 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])}", + blast_radius=BlastRadius.SERVICE, + data_impact=DataImpact.READ_ONLY, + dry_run_check=DryRunCheck.PASSED, + llm_provider=analysis.analyzed_by, + llm_confidence=analysis.confidence, + metadata={ + "github_review_id": review_id, + "repo": repo, + "target": target, + "url": url, + "quality_score": analysis.quality_score, + "security_concerns": analysis.security_concerns, + "issues_count": len(analysis.issues), + }, + ) + + # 創建 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]}" + + +# ============================================================================= +# Query Endpoints +# ============================================================================= + +@router.get( + "/reviews/{review_id}", + summary="取得審查結果", + description="根據 review_id 取得 GitHub 代碼審查結果", +) +async def get_review_result(review_id: str): + """ + 取得 GitHub 審查結果 (透過 Service) + + Args: + review_id: 審查 ID + + Returns: + dict: 審查結果 + """ + service = get_github_webhook_service() + result = await service.get_review_result(review_id) + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Review {review_id} not found or expired", + ) + + return result diff --git a/apps/api/src/services/github_webhook_service.py b/apps/api/src/services/github_webhook_service.py new file mode 100644 index 000000000..47513244d --- /dev/null +++ b/apps/api/src/services/github_webhook_service.py @@ -0,0 +1,121 @@ +""" +GitHub Webhook Service - Phase 13.1 +==================================== +封裝 GitHub Webhook 相關的 Redis 操作 + +遵循 leWOOOgo 積木化原則: +- Router 層不直接存取 Redis +- 透過 Service 層封裝資料存取邏輯 +""" + +import json +from typing import Protocol + +import structlog + +from src.core.redis_client import get_redis + +logger = structlog.get_logger(__name__) + + +# Redis TTL: 7 天 +GITHUB_REVIEW_TTL_SECONDS = 7 * 24 * 60 * 60 + + +class IGitHubReviewRepository(Protocol): + """GitHub 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 GitHubReviewRedisRepository: + """Redis 實作的 GitHub Review Repository""" + + KEY_PREFIX = "github_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=GITHUB_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 + + +class GitHubWebhookService: + """ + GitHub Webhook 服務 + + 封裝審查結果的儲存與查詢 + """ + + def __init__(self, repository: IGitHubReviewRepository | None = None): + self._repository = repository or GitHubReviewRedisRepository() + + async def save_review_result( + self, + review_id: str, + review_data: dict, + ) -> bool: + """儲存審查結果""" + 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) + + +# 單例 +_service: GitHubWebhookService | None = None + + +def get_github_webhook_service() -> GitHubWebhookService: + """取得 GitHubWebhookService 單例""" + global _service + if _service is None: + _service = GitHubWebhookService() + return _service diff --git a/apps/api/tests/test_github_webhook.py b/apps/api/tests/test_github_webhook.py new file mode 100644 index 000000000..bafd46d1a --- /dev/null +++ b/apps/api/tests/test_github_webhook.py @@ -0,0 +1,516 @@ +""" +Phase 13.1: GitHub Webhook 整合測試 +=================================== +測試 GitHub Webhook → OpenClaw AI 代碼審查整合 + +測試策略 (遵循 feedback_no_mock_testing.md): +- 使用 ASGITransport 撞擊真實端點 +- 不使用 Mock,直接測試 HTTP 層 +- 驗證 HMAC 簽章邏輯 + +🔴 IMPORTANT: 禁止 Mock 測試! +""" + +import hashlib +import hmac +import json +import os + +import httpx +import pytest +from httpx import ASGITransport + +# 設定測試環境變數 (在 import main 之前) +os.environ.setdefault("MOCK_MODE", "true") +os.environ.setdefault("ENVIRONMENT", "dev") +os.environ.setdefault("GITHUB_WEBHOOK_SECRET", "test-secret-key-12345") +os.environ.setdefault("GITHUB_ALLOWED_REPOS", "test-owner/test-repo,wooo-ai/awoooi") + +from src.main import app # noqa: E402 + + +# ============================================================================= +# Test Fixtures +# ============================================================================= + +@pytest.fixture +def webhook_secret(): + """測試用 Webhook Secret""" + return "test-secret-key-12345" + + +@pytest.fixture +def sample_pr_payload(): + """範例 PR Payload""" + return { + "action": "opened", + "repository": { + "id": 123456, + "name": "test-repo", + "full_name": "test-owner/test-repo", + "private": False, + "html_url": "https://github.com/test-owner/test-repo", + }, + "sender": { + "login": "test-user", + "id": 1, + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + }, + "pull_request": { + "id": 1, + "number": 42, + "title": "Add new feature", + "body": "This PR adds a new feature", + "state": "open", + "html_url": "https://github.com/test-owner/test-repo/pull/42", + "diff_url": "https://github.com/test-owner/test-repo/pull/42.diff", + "user": { + "login": "test-user", + "id": 1, + }, + "head": {"ref": "feature-branch", "sha": "abc123"}, + "base": {"ref": "main", "sha": "def456"}, + "additions": 50, + "deletions": 10, + "changed_files": 3, + }, + } + + +@pytest.fixture +def sample_push_payload(): + """範例 Push Payload""" + return { + "ref": "refs/heads/main", + "before": "0000000000000000000000000000000000000000", + "after": "abc1234567890abcdef1234567890abcdef12345", + "repository": { + "id": 123456, + "name": "test-repo", + "full_name": "test-owner/test-repo", + "private": False, + "html_url": "https://github.com/test-owner/test-repo", + }, + "sender": { + "login": "test-user", + "id": 1, + }, + "pusher": { + "name": "test-user", + "email": "test@example.com", + }, + "commits": [ + { + "id": "abc1234567890abcdef1234567890abcdef12345", + "message": "feat: add new feature", + "timestamp": "2026-03-26T10:00:00+08:00", + "url": "https://github.com/test-owner/test-repo/commit/abc123", + "author": {"name": "Test User", "email": "test@example.com"}, + "added": ["new_file.py"], + "removed": [], + "modified": ["existing_file.py"], + } + ], + } + + +@pytest.fixture +def ping_payload(): + """Ping 事件 Payload""" + return { + "zen": "Responsive is better than fast.", + "hook_id": 12345, + "repository": { + "id": 123456, + "name": "test-repo", + "full_name": "test-owner/test-repo", + "private": False, + "html_url": "https://github.com/test-owner/test-repo", + }, + "sender": { + "login": "test-user", + "id": 1, + }, + } + + +def generate_signature(secret: str, body: bytes) -> str: + """生成 GitHub Webhook 簽章""" + signature = hmac.new( + secret.encode(), + body, + hashlib.sha256, + ).hexdigest() + return f"sha256={signature}" + + +def prepare_request(secret: str, payload: dict) -> tuple[bytes, str]: + """準備請求 body 和簽章""" + body = json.dumps(payload, separators=(',', ':')).encode() + signature = generate_signature(secret, body) + return body, signature + + +# ============================================================================= +# HMAC 簽章驗證測試 +# ============================================================================= + +@pytest.mark.asyncio +async def test_webhook_missing_signature(sample_pr_payload): + """測試: 缺少簽章應該被拒絕""" + body = json.dumps(sample_pr_payload, separators=(',', ':')).encode() + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "test-delivery-id", + # 故意不提供 X-Hub-Signature-256 + }, + ) + + # 在 dev 環境下,缺少 secret 但配置了 secret,應該要求簽章 + # 由於我們設定了 GITHUB_WEBHOOK_SECRET,缺少簽章應該返回 401 + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_webhook_invalid_signature(sample_pr_payload): + """測試: 無效簽章應該被拒絕""" + body = json.dumps(sample_pr_payload, separators=(',', ':')).encode() + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": "sha256=invalid_signature_here", + }, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_webhook_valid_signature(sample_pr_payload, webhook_secret): + """測試: 有效簽章應該被接受""" + body, signature = prepare_request(webhook_secret, sample_pr_payload) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": signature, + }, + ) + + # 應該返回 202 Accepted + assert response.status_code == 202 + data = response.json() + assert data["status"] == "accepted" + assert data["event_type"] == "pull_request" + assert data["review_id"] is not None + + +# ============================================================================= +# 倉庫白名單測試 +# ============================================================================= + +@pytest.mark.asyncio +async def test_webhook_repo_not_in_whitelist(webhook_secret): + """測試: 不在白名單的倉庫應該被忽略""" + payload = { + "action": "opened", + "repository": { + "id": 999999, + "name": "unauthorized-repo", + "full_name": "unknown-owner/unauthorized-repo", # 不在白名單 + "private": False, + "html_url": "https://github.com/unknown-owner/unauthorized-repo", + }, + "sender": { + "login": "hacker", + "id": 999, + }, + "pull_request": { + "id": 1, + "number": 1, + "title": "Malicious PR", + "state": "open", + "html_url": "https://github.com/unknown-owner/unauthorized-repo/pull/1", + "diff_url": "https://github.com/unknown-owner/unauthorized-repo/pull/1.diff", + "user": {"login": "hacker", "id": 999}, + "head": {"ref": "evil", "sha": "bad123"}, + "base": {"ref": "main", "sha": "good456"}, + }, + } + + body, signature = prepare_request(webhook_secret, payload) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": signature, + }, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "ignored" + assert "not in whitelist" in data["message"] + + +# ============================================================================= +# 事件類型測試 +# ============================================================================= + +@pytest.mark.asyncio +async def test_webhook_ping_event(ping_payload, webhook_secret): + """測試: Ping 事件應該回應 Pong""" + body, signature = prepare_request(webhook_secret, ping_payload) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": signature, + }, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "accepted" + assert "Pong" in data["message"] + assert data["event_type"] == "ping" + + +@pytest.mark.asyncio +async def test_webhook_push_event(sample_push_payload, webhook_secret): + """測試: Push 到主分支應該觸發審查""" + body, signature = prepare_request(webhook_secret, sample_push_payload) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "push", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": signature, + }, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "accepted" + assert data["event_type"] == "push" + assert data["review_id"] is not None + + +@pytest.mark.asyncio +async def test_webhook_push_non_default_branch(webhook_secret): + """測試: Push 到非主分支應該被忽略""" + payload = { + "ref": "refs/heads/feature-branch", # 非主分支 + "before": "0000000000000000000000000000000000000000", + "after": "abc123", + "repository": { + "id": 123456, + "name": "test-repo", + "full_name": "test-owner/test-repo", + "private": False, + "html_url": "https://github.com/test-owner/test-repo", + }, + "sender": {"login": "test-user", "id": 1}, + "commits": [ + { + "id": "abc123", + "message": "feature commit", + "timestamp": "2026-03-26T10:00:00+08:00", + "url": "https://github.com/test-owner/test-repo/commit/abc123", + "author": {"name": "Test", "email": "test@example.com"}, + } + ], + } + + body, signature = prepare_request(webhook_secret, payload) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "push", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": signature, + }, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "ignored" + assert "non-default branch" in data["message"] + + +@pytest.mark.asyncio +async def test_webhook_unsupported_event(webhook_secret): + """測試: 不支援的事件類型應該被忽略""" + payload = { + "action": "added", + "repository": { + "id": 123456, + "name": "test-repo", + "full_name": "test-owner/test-repo", + "private": False, + "html_url": "https://github.com/test-owner/test-repo", + }, + "sender": {"login": "test-user", "id": 1}, + } + + body, signature = prepare_request(webhook_secret, payload) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "star", # 不支援的事件 + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": signature, + }, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "ignored" + assert "not supported" in data["message"] + + +@pytest.mark.asyncio +async def test_webhook_pr_unsupported_action(webhook_secret): + """測試: PR 不支援的 action 應該被忽略""" + payload = { + "action": "closed", # 我們只處理 opened, synchronize, reopened + "repository": { + "id": 123456, + "name": "test-repo", + "full_name": "test-owner/test-repo", + "private": False, + "html_url": "https://github.com/test-owner/test-repo", + }, + "sender": {"login": "test-user", "id": 1}, + "pull_request": { + "id": 1, + "number": 42, + "title": "Closed PR", + "state": "closed", + "html_url": "https://github.com/test-owner/test-repo/pull/42", + "diff_url": "https://github.com/test-owner/test-repo/pull/42.diff", + "user": {"login": "test-user", "id": 1}, + "head": {"ref": "feature", "sha": "abc"}, + "base": {"ref": "main", "sha": "def"}, + }, + } + + body, signature = prepare_request(webhook_secret, payload) + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": signature, + }, + ) + + assert response.status_code == 202 + data = response.json() + assert data["status"] == "ignored" + assert "not supported" in data["message"] + + +# ============================================================================= +# 簽章格式測試 +# ============================================================================= + +@pytest.mark.asyncio +async def test_webhook_wrong_signature_format(sample_pr_payload): + """測試: 錯誤的簽章格式應該被拒絕""" + body = json.dumps(sample_pr_payload, separators=(',', ':')).encode() + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/webhooks/github", + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "test-delivery-id", + "X-Hub-Signature-256": "md5=wrong_format", # 錯誤格式 + }, + ) + + assert response.status_code == 401 + + +# ============================================================================= +# 執行測試 +# ============================================================================= + +if __name__ == "__main__": + pytest.main([__file__, "-v"])